From 5306423a8e91402fe87986b31ed9a0db601cd6ed Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 12:17:30 +0530 Subject: [PATCH 01/40] perf(retouch): releasing a Blur/Smudge/Dodge stroke takes its live pixels Releasing a Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke ran every dab of the stroke again on the UI thread (70-350 ms for a 960 px stroke on 24 MP, growing with stroke length). The Brush, Clone Stamp and History Brush already hand their live pixels to the commit; LiveDab didn't, and its preview wasn't the commit's pixels anyway: its working copy was a native-depth Surface rounded after every dab (at 8-bit a live Blur differed on 21% of pixels by up to 3 levels), the finishing dabs weren't run, and canvas points carried tilt/times the commands don't take. - LiveDab keeps its working copy in f32 tiles (WorkTiles), drops tilt and times like LiveRetouch, and into_commit runs the finishing dabs. - Session::offer_live_dab; dab_cmd takes a matching offer ("fromLive"). Refused when the brush, tool options or layer changed mid-stroke, and for Color Replacement. - mix_back leaves pixels the dabs didn't change exactly as they were. - The canvas offers the live stroke on release. Release: Blur 20/70/140/303 ms -> 0.2-0.4 ms at 50/150/300/600 px; Smudge and Dodge likewise. Live frame cost unchanged. Co-Authored-By: Claude Opus 5.5 --- .../engine/examples/bench_live_dab_release.rs | 87 ++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/retouch_cmds.rs | 186 ++++++++++++++++-- crates/engine/src/retouch_cmds/tests.rs | 113 +++++++++++ crates/paint/src/retouch.rs | 4 + crates/ui-egui/src/canvas.rs | 30 ++- crates/ui-egui/src/stroke_timing_tests.rs | 40 ++++ 7 files changed, 442 insertions(+), 27 deletions(-) create mode 100644 crates/engine/examples/bench_live_dab_release.rs diff --git a/crates/engine/examples/bench_live_dab_release.rs b/crates/engine/examples/bench_live_dab_release.rs new file mode 100644 index 0000000000..f6935d3210 --- /dev/null +++ b/crates/engine/examples/bench_live_dab_release.rs @@ -0,0 +1,87 @@ +//! Release cost of a live sequential-dab stroke on a 24 MP layer, in release mode: the commit +//! rendering the stroke again (no offer) against taking the live pixels (`offer_live_dab`, which +//! runs the finishing dabs, plus the commit). Also the live stroke's per-frame cost while drawn. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab}; +use photocraft_geom::Rect; +use serde_json::json; + +const FRAMES: usize = 60; + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1e3 +} + +fn run() -> Result<(), Box> { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":8,"mode":"rgb"}))?; + s.execute("layer.new.layer", json!({}))?; + s.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + for x in (0..6000).step_by(64) { + let c = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &c); + } + Ok(()) + })?; + // ~1000 px/s at 60 fps: 16 px of drag per frame, in four samples. + let pts: Vec<[f64; 2]> = std::iter::once([1000.0, 2000.0]) + .chain((1..=FRAMES).flat_map(|f| (1..=4).map(move |k| [1000.0 + (f as f64 - 1.0) * 16.0 + k as f64 * 4.0, 2000.0]))) + .collect(); + for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] { + for size in [50, 150, 300, 600] { + let p = |v: &[[f64; 2]]| json!({"points": v, "size": size, "hardness": 0, "strength": 50, "spacing": 25}); + let draw = |s: &Session| -> Result<(LiveDab, Vec), Box> { + let mut live = LiveDab::begin(s, cmd, &p(&pts[..1]))?; + let mut frames = Vec::new(); + for batch in pts[1..].chunks(4) { + let b: Vec<_> = batch.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + let t = Instant::now(); + live.push(&b)?; + frames.push(ms(t)); + } + Ok((live, frames)) + }; + let (mut before, mut after) = (Vec::new(), Vec::new()); + let mut frames = Vec::new(); + for _ in 0..3 { + let (_, f) = draw(&s)?; + frames.extend(f); + let t = Instant::now(); + s.execute(cmd, p(&pts))?; + before.push(ms(t)); + s.execute("edit.undo", json!({}))?; + let (live, _) = draw(&s)?; + let t = Instant::now(); + s.offer_live_dab(live)?; + let r = s.execute(cmd, p(&pts))?; + after.push(ms(t)); + if r.get("fromLive").is_none() { + return Err("the commit didn't take the live pixels".into()); + } + s.execute("edit.undo", json!({}))?; + } + before.sort_by(f64::total_cmp); + after.sort_by(f64::total_cmp); + frames.sort_by(f64::total_cmp); + let total: f64 = frames.iter().sum::() / 3.0; + println!( + "{cmd:12} size={size:4} release: render again={:7.1} ms take live={:6.1} ms | live frames: max={:6.2} ms total={:7.1} ms", + before[1], + after[1], + frames.last().copied().unwrap_or(0.0), + total + ); + } + } + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b076769040..4066557692 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -672,6 +672,15 @@ impl Session { Ok(()) } + /// [`Self::offer_live_stroke`] for a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke, + /// taken by the command it was drawn for: the stroke's finishing dabs are run first. Refused + /// when the brush, tool options or layer changed during the stroke. + pub fn offer_live_dab(&mut self, live: retouch_cmds::LiveDab) -> Result<()> { + let commit = live.into_commit(self)?; + self.live_commit = Some(commit); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..fdbee1cb21 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -900,7 +900,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result { /// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and /// turned into its per-dab effect, for the commit and the live preview alike. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, @@ -1008,7 +1008,15 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?; let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p); + // The live stroke the canvas already drew for exactly this commit (`LiveDab::into_commit`): + // take its pixels instead of running every dab again (the hitch on release). + let live = s.live_commit.take().filter(|l| l.matches(s, cmd, p, &stroke.points)); + let from_live = live.is_some(); let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); let mut sampler = all.then(|| AllLayers::new(pre, fmt)); @@ -1017,12 +1025,93 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { None => effect(work, fp), })) })?; - Ok(json!({ "damage": damage_json(dmg) })) + let mut r = json!({ "damage": damage_json(dmg) }); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) +} + +/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). +const WORK_TILE: i32 = 128; + +/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in +/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a +/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the +/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. +struct WorkTiles { + ch: usize, + tiles: std::collections::HashMap<(i32, i32), Vec>, +} + +impl WorkTiles { + fn new(ch: usize) -> Self { + Self { ch, tiles: std::collections::HashMap::new() } + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + fn read(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store `region` as the working pixels of its rectangle. + fn write(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } } /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. +/// The working copy keeps full precision ([`WorkTiles`]), so the pixels are the commit's, and the +/// commit takes them over ([`Session::offer_live_dab`]) instead of running every dab again. /// Sample All Layers isn't previewed (its composite spans the whole stroke). pub struct LiveDab { /// The active document with the stroke so far. @@ -1033,7 +1122,7 @@ pub struct LiveDab { effect: DabEffect, halo: i32, pre_surf: Surface, - work: Surface, + work: WorkTiles, id: Option, params: Value, opacity: f32, @@ -1047,6 +1136,14 @@ pub struct LiveDab { preview_tail: bool, /// Where the shown tail is. tail: Rect, + /// The tool and brush the stroke started with, which its commit must still have: the + /// document revision doesn't cover the session's brush or foreground colour. `None` for + /// strokes whose commit renders itself (Color Replacement). + tool: Option<(DabTool, Box)>, + /// The document and revision the stroke started on. + base: (photocraft_doc::DocId, u64), + /// Every point rendered so far, in order. + points: Vec, } impl LiveDab { @@ -1058,8 +1155,11 @@ impl LiveDab { } let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; - let (halo, spacing) = (tool.halo(), stroke.brush.spacing); - Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing)) + let (halo, spacing, brush) = (tool.halo(), stroke.brush.spacing, Box::new(stroke.brush.clone())); + let effect = tool.clone(); + let mut live = Self::with_effect(s, cmd, p, stroke, id, halo, |pre| effect.effect(pre.format(), spacing))?; + live.tool = Some((tool, brush)); + Ok(live) } /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) @@ -1073,7 +1173,9 @@ impl LiveDab { halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + let st = s.active().ok_or(EngineError::NoDocument)?; + let base = (st.doc.id, st.revision); + let mut doc = (*st.doc).clone(); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); @@ -1087,7 +1189,7 @@ impl LiveDab { done: 0, effect, halo, - work: pre_surf.clone(), + work: WorkTiles::new(pre_surf.channels()), pre_surf, id, params: p.clone(), @@ -1097,6 +1199,9 @@ impl LiveDab { bounds: Rect::EMPTY, preview_tail: false, tail: Rect::EMPTY, + tool: None, + base, + points: Vec::new(), }; live.push(&stroke.points)?; Ok(live) @@ -1114,11 +1219,9 @@ impl LiveDab { self.bounds } - /// Render more points; returns the rectangle that changed. - /// Invalid coordinates reject the whole batch without changing the preview. - pub fn push(&mut self, pts: &[StrokePoint]) -> Result { - crate::brush_cmds::check_coords(pts, &self.cmd)?; - self.renderer.push(pts); + /// Run the dabs the renderer has generated since the last call on the working copy; returns + /// the rectangle they changed. + fn run_new_dabs(&mut self) -> Rect { let ctx = self.renderer.ctx.clone(); let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); let mut dmg = Rect::EMPTY; @@ -1127,19 +1230,39 @@ impl LiveDab { if r.is_empty() { continue; } - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - region.crop(r).write(&mut self.work); + self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); } self.done += new.len(); + dmg + } + + /// Mix the working pixels of `r` back over the original into the shown document. + fn show(&mut self, r: Rect, work: &Region) -> Result<()> { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), work, r, self.opacity, self.sel.as_ref(), self.lock); + Ok(()) + } + + /// Render more points; returns the rectangle that changed. + /// Invalid coordinates reject the whole batch without changing the preview. + /// Only the position and pressure of `pts` are used: the commands take no tilt or times. + pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + crate::brush_cmds::check_coords(pts, &self.cmd)?; + let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); + self.points.extend_from_slice(&pts); + self.renderer.push(&pts); + let dmg = self.run_new_dabs(); + let ctx = self.renderer.ctx.clone(); // The finishing dabs, run on a scratch copy of the working pixels. let mut tail = None; if self.preview_tail { let ds = self.renderer.tail_dabs(); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); for (k, d) in ds.iter().enumerate() { (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); } @@ -1154,17 +1277,42 @@ impl LiveDab { return Ok(Rect::EMPTY); } self.bounds = self.bounds.union(&dmg).union(&tail_rect); - let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; if !shown.is_empty() { - let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown)); - photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); + let work = self.work.read(&self.pre_surf, shown); + self.show(shown, &work)?; } if let Some((r, work)) = tail { - photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock); + self.show(r, &work)?; self.tail = r; } Ok(shown.union(&tail_rect)) } + + /// The finished stroke as its commit would take it over ([`Session::offer_live_dab`]): the + /// finishing dabs are run, and the pixels are the ones the command with the same params and + /// points paints. Only while the brush, tool settings and layer are still the ones the stroke + /// started with: the document revision doesn't cover those. + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let Some((tool, brush)) = self.tool.take() else { + return Err(bad(&self.cmd, "this stroke's commit renders itself")); + }; + let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?; + let (now, _) = DabTool::parse(s, &self.cmd, &self.params, &stroke)?; + if now != tool || stroke.brush != *brush || id != self.id { + return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke")); + } + self.renderer.finish(); + let dmg = self.run_new_dabs(); + let shown = dmg.union(&self.tail); + if !shown.is_empty() { + let work = self.work.read(&self.pre_surf, shown); + self.show(shown, &work)?; + } + self.bounds = self.bounds.union(&dmg); + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + let surface = surf.clone(); + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds: self.bounds }) + } } /// "Sample All Layers" for Spot Healing, Blur, Sharpen and Smudge (layer pixels only: a mask or diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e20706857a..c25228d0b4 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1601,6 +1601,119 @@ fn live_dodge_matches_the_commit_across_cells() { assert!(worst <= 2e-4, "live and commit differ by {worst}"); } +/// The sequential-dab tools' options for the live hand-off tests. +fn dab_cases() -> [(&'static str, Value); 6] { + [ + ("paint.dodge", json!({"exposure": 60, "range": "midtones"})), + ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false})), + ("paint.sponge", json!({"mode": "saturate"})), + ("paint.blur", json!({"strength": 80})), + ("paint.sharpen", json!({"strength": 60})), + ("paint.smudge", json!({"strength": 70})), + ] +} + +/// A stroke's params: `points`, a 40 px brush of `hardness`, and the tool's options. +fn dab_opts(points: &[[f64; 2]], hardness: u32, extra: &Value) -> Value { + let mut o = json!({"points": points, "size": 40, "hardness": hardness, "spacing": 25}); + for (k, v) in extra.as_object().unwrap() { + o[k] = v.clone(); + } + o +} + +/// The active layer's pixels over the whole canvas. +fn layer_pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) +} + +/// Draw `points` live as the canvas does (the first with `begin`, the rest in pen batches that +/// carry tilt and times, which the commands don't take) and offer the stroke to its commit. +fn offer_live(s: &mut Session, cmd: &str, points: &[[f64; 2]], hardness: u32, extra: &Value) { + let mut live = LiveDab::begin(s, cmd, &dab_opts(&points[..1], hardness, extra)).unwrap(); + for (i, batch) in points[1..].chunks(2).enumerate() { + let pts: Vec<_> = batch + .iter() + .map(|q| { + let mut p = StrokePoint::new(q[0], q[1], 1.0); + (p.tilt_x, p.time) = (25.0, 16.0 * i as f64); + p + }) + .collect(); + live.push(&pts).unwrap(); + } + s.offer_live_dab(live).unwrap(); +} + +/// Releasing a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke commits the pixels it +/// already drew instead of running every dab again, and they are exactly what the command paints +/// on its own: at every depth (the live working copy isn't rounded between dabs), soft and hard, +/// over semi-transparent pixels and where the stroke doubles back. +#[test] +fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() { + let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0], [60.0, 30.0], [150.0, 70.0], [151.0, 70.5], [-15.0, 85.0]]; + for depth in DEPTHS { + let mut s = session(200, 90, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, y| { + let mut c = texture(x, y); + c[3] = if (x / 23 + y / 17) % 4 == 0 { 0.0 } else { 0.35 + 0.065 * ((x * 7 + y * 3) % 11) as f32 }; + c + }); + let before = layer_pixels(&s); + for (cmd, extra) in dab_cases() { + for hardness in [0, 100] { + let rendered = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap(); + let want = layer_pixels(&s); + assert_ne!(want, before, "{cmd} {depth} {hardness}: the stroke changes pixels"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(rendered.get("fromLive").is_none()); + + offer_live(&mut s, cmd, &points, hardness, &extra); + let taken = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{cmd} {depth} {hardness}"); + assert_eq!(taken["damage"], rendered["damage"], "{cmd} {depth} {hardness}"); + assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer"); + } + } + } +} + +/// The offer is only taken by its own stroke: another stroke, a brush or option changed during +/// the stroke, or a stroke whose commit renders itself (Color Replacement) render as before. +#[test] +fn live_dab_offers_are_refused_or_dropped_when_they_dont_match() { + let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0]]; + let blur = json!({"strength": 80}); + let mut s = session(200, 90, 8, "rgb"); + paint_layer(&mut s, texture); + // Another stroke drops the offer and renders itself. + offer_live(&mut s, "paint.blur", &points, 0, &blur); + let other = [[10.0, 40.0], [100.0, 61.0], [180.0, 40.0]]; + let r = s.execute("paint.blur", dab_opts(&other, 0, &blur)).unwrap(); + assert!(r.get("fromLive").is_none()); + assert!(s.live_commit.is_none(), "the offer is used up"); + s.execute("edit.undo", json!({})).unwrap(); + // The session brush (not in the params) changed during the stroke. + let live = LiveDab::begin(&s, "paint.blur", &dab_opts(&points, 0, &blur)).unwrap(); + let roundness = s.tools.brush.roundness; + s.tools.brush.roundness = roundness * 0.5; + assert!(s.offer_live_dab(live).is_err()); + s.tools.brush.roundness = roundness; + // Finger Painting's colour changed during the stroke. + let finger = json!({"strength": 70, "fingerPainting": true}); + let live = LiveDab::begin(&s, "paint.smudge", &dab_opts(&points, 0, &finger)).unwrap(); + s.tools.foreground = [0.9, 0.1, 0.1, 1.0]; + assert!(s.offer_live_dab(live).is_err()); + // Color Replacement's live stroke runs on `LiveDab` too, but its commit renders itself. + let live = crate::brush_cmds::live_color_replacement(&s, &json!({"points": points, "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + assert!(s.offer_live_dab(live).is_err()); + assert!(s.live_commit.is_none()); +} + #[test] fn retouch_strokes_refuse_absurd_point_coordinates() { // #976: a segment to (1e300, 0) has an infinite length and the dab walker would never stop, so diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 9ef837c0c0..16d52e1d5b 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -263,6 +263,10 @@ pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect, let wv = work.px(x, y); let i = out.index(x, y) * n; let o = &mut out.data[i..i + n]; + // Untouched by the dabs: keep the pixel exactly (the premultiplied mix can round it). + if *o == *wv { + continue; + } match a { // Alpha changes: mix premultiplied, so colour under transparent pixels doesn't bleed in. Some(a) if !lock_transparency => mix_premultiplied(o, wv, a, k), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ca0bac7c2..a5eb6ed98f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -755,16 +755,30 @@ pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect(); - if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_retouch(live); + // A refused offer (the brush changed mid-stroke, say) leaves the commit to render the stroke. + match stroke { + EngineStroke::Retouch(mut live) if !dab => { + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_retouch(live); + } + } + EngineStroke::Dab(mut live) if dab => { + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_dab(live); + } + } + _ => {} } } @@ -5819,8 +5833,8 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { { app.last_stroke_end = Some((st.doc.id, end)); } - // A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and - // History Brush's with the pixels it already drew). + // A live retouching preview ends here; the commit below replaces it (the Clone Stamp's, + // History Brush's and sequential-dab tools' with the pixels it already drew). offer_live_retouch(app, &d); if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index a472a4125c..e800887962 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -425,3 +425,43 @@ fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() { assert_eq!(a.px.len(), b.px.len()); assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ"); } + +/// A Blur, Smudge or Dodge stroke through the canvas: releasing it commits the pixels its live +/// stroke drew (`Session::offer_live_dab`), which are what replaying the journaled command paints. +#[test] +fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { + for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); + run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + h.state_mut().ui.tool = tool; + h.run_steps(1); + move_to(&mut h, 40.0, 30.0); + button(&mut h, 40.0, 30.0, true); + for (x, y) in [(70.0, 40.0), (100.0, 55.0), (130.0, 50.0), (160.0, 70.0)] { + move_to(&mut h, x, y); + } + assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); + button(&mut h, 160.0, 70.0, false); + h.run_steps(2); + let app = h.state(); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + let replayed = fresh.execute(cmd, p).unwrap(); + assert!(replayed.get("fromLive").is_none()); + let st = app.session.active().unwrap(); + let a = photocraft_compose::render(&st.doc, st.doc.bounds()); + let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()); + assert_eq!(a.px.len(), b.px.len()); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); + } +} From defec592030db5234ca284652c3d28243e5e9c94 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 12:43:51 +0530 Subject: [PATCH 02/40] review: a refused live-dab offer withdraws the previous one; test the canvas hand-off - Session::offer_live_dab clears any earlier offer before converting, so a refused offer can't leave an older one behind (test fails without it). - retouch_ui::finish_stroke records whether the commit took the live pixels (PhotocraftApp::retouch_from_live), and the ui-egui release test asserts it, so a second render on release can't pass unnoticed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/retouch_cmds/tests.rs | 5 ++++- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/retouch_ui.rs | 4 +++- crates/ui-egui/src/stroke_timing_tests.rs | 2 ++ 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4066557692..ec24dab33b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -676,6 +676,8 @@ impl Session { /// taken by the command it was drawn for: the stroke's finishing dabs are run first. Refused /// when the brush, tool options or layer changed during the stroke. pub fn offer_live_dab(&mut self, live: retouch_cmds::LiveDab) -> Result<()> { + // A refused offer withdraws the previous one too. + self.live_commit = None; let commit = live.into_commit(self)?; self.live_commit = Some(commit); Ok(()) diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index c25228d0b4..556bead4b2 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1697,11 +1697,14 @@ fn live_dab_offers_are_refused_or_dropped_when_they_dont_match() { assert!(r.get("fromLive").is_none()); assert!(s.live_commit.is_none(), "the offer is used up"); s.execute("edit.undo", json!({})).unwrap(); - // The session brush (not in the params) changed during the stroke. + // The session brush (not in the params) changed during the stroke; the refusal also + // withdraws an earlier offer. + offer_live(&mut s, "paint.blur", &points, 0, &blur); let live = LiveDab::begin(&s, "paint.blur", &dab_opts(&points, 0, &blur)).unwrap(); let roundness = s.tools.brush.roundness; s.tools.brush.roundness = roundness * 0.5; assert!(s.offer_live_dab(live).is_err()); + assert!(s.live_commit.is_none(), "a refused offer leaves no earlier one behind"); s.tools.brush.roundness = roundness; // Finger Painting's colour changed during the stroke. let finger = json!({"strength": 70, "fingerPainting": true}); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6685b9b77..a129469b1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -591,6 +591,9 @@ pub struct PhotocraftApp { /// A live stroke just committed with its own pixels: the canvas caches that showed it /// refresh only what it drew after the step they showed (`canvas::damage_since`). committed_stroke: Option, + /// Whether the last retouching stroke's commit took the pixels its live stroke drew + /// (`"fromLive"`) instead of rendering the stroke again (`retouch_ui::finish_stroke`). + pub(crate) retouch_from_live: bool, /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the @@ -863,6 +866,7 @@ impl PhotocraftApp { tool_override: None, live_stroke: None, committed_stroke: None, + retouch_from_live: false, trail: None, remove_pending: Vec::new(), remove_pending_doc: None, diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index adf3c8cf69..ad9befbe81 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -144,7 +144,9 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m p["points"] = pts; // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); - match app.run(cmd, p) { + let result = app.run(cmd, p); + app.retouch_from_live = matches!(&result, Ok(r) if r.get("fromLive").and_then(Value::as_bool) == Some(true)); + match result { Ok(r) if matches!(tool, Tool::Healing | Tool::CloneStamp) => { // Aligned: keep the offset for later strokes; non-aligned: every stroke restarts at the source. app.ui.clone_offset = r.get("offset").and_then(|v| serde_json::from_value(v.clone()).ok()).filter(|_| o.clone_aligned); diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index e800887962..93dd466561 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -447,9 +447,11 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { move_to(&mut h, x, y); } assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); + h.state_mut().retouch_from_live = false; button(&mut h, 160.0, 70.0, false); h.run_steps(2); let app = h.state(); + assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); let mut fresh = photocraft_engine::Session::new(); fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); From 0635061b865e5c4e8ce63f24834f32f082577ee4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 19 Oct 2026 13:58:58 -0600 Subject: [PATCH 03/40] fix(retouch): preserve Smudge pickup through live stroke release --- crates/engine/src/retouch_cmds.rs | 64 ++++++- crates/engine/tests/pr7281_adversarial.rs | 216 ++++++++++++++++++++++ crates/paint/src/retouch.rs | 8 +- crates/ui-egui/src/stroke_timing_tests.rs | 56 ++++++ 4 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/pr7281_adversarial.rs diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 152e4733b5..db47fcacec 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -992,6 +992,37 @@ enum DabTool { /// One dab's edit of the working copy. pub(crate) type DabEffect = Box; +/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square, +/// independent of pressure, roundness and the dab's current angle. +#[derive(Clone, Copy)] +enum DabNeighborhood { + Halo(i32), + Smudge(i32), +} + +impl DabNeighborhood { + fn halo(self) -> i32 { + match self { + Self::Halo(radius) | Self::Smudge(radius) => radius, + } + } + + fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect { + match self { + Self::Halo(radius) => rect.inflate(radius), + Self::Smudge(radius) => { + let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32); + rect.union(&Rect::new( + x.saturating_sub(radius), + y.saturating_sub(radius), + x.saturating_add(radius).saturating_add(1), + y.saturating_add(radius).saturating_add(1), + )) + } + } + } +} + impl DabTool { /// The tool for `cmd` and its history label. fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { @@ -1033,10 +1064,11 @@ impl DabTool { } /// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood). - fn halo(&self) -> i32 { + fn neighborhood(&self) -> DabNeighborhood { match self { - Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1, - _ => 0, + Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1), + Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)), + _ => DabNeighborhood::Halo(0), } } @@ -1108,7 +1140,7 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler { + Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -1208,7 +1240,7 @@ pub struct LiveDab { renderer: photocraft_paint::StrokeRenderer, done: usize, effect: DabEffect, - halo: i32, + neighborhood: DabNeighborhood, pre_surf: Surface, work: WorkTiles, id: Option, @@ -1244,9 +1276,9 @@ impl LiveDab { } let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; - let (halo, spacing, brush) = (tool.halo(), stroke.brush.spacing, Box::new(stroke.brush.clone())); + let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_effect(s, cmd, p, stroke, id, halo, |pre| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } @@ -1261,6 +1293,18 @@ impl LiveDab { id: Option, halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { + Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) + } + + fn with_neighborhood( + s: &Session, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + neighborhood: DabNeighborhood, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); @@ -1293,7 +1337,7 @@ impl LiveDab { renderer, done: 0, effect, - halo, + neighborhood, work: WorkTiles::new(pre_surf.channels()), pre_surf, id, @@ -1335,7 +1379,7 @@ impl LiveDab { if r.is_empty() { continue; } - let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); @@ -1367,7 +1411,7 @@ impl LiveDab { let ds = self.renderer.tail_dabs(); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { - let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo())); for (k, d) in ds.iter().enumerate() { (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); } diff --git a/crates/engine/tests/pr7281_adversarial.rs b/crates/engine/tests/pr7281_adversarial.rs new file mode 100644 index 0000000000..aae7e32b53 --- /dev/null +++ b/crates/engine/tests/pr7281_adversarial.rs @@ -0,0 +1,216 @@ +use photocraft_engine::{Session, retouch_cmds::LiveDab}; +use photocraft_paint::StrokePoint; +use serde_json::json; + +fn setup(depth: u32, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":200,"height":120,"depth":depth,"mode":mode})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("texture", |doc, active| { + let bounds = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut px = Vec::new(); + for y in 0..120 { + for x in 0..200 { + px.extend(photocraft_raster::from_rgba( + &fmt, + [((x * 7 + y * 3) % 31) as f32 / 31.0, x as f32 / 200.0, y as f32 / 120.0, 0.4 + ((x + y) % 7) as f32 / 12.0], + )); + } + } + surf.write_region(bounds, &px); + Ok(()) + }) + .unwrap(); + s +} + +fn pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) +} + +#[test] +fn pressure_shape_and_smoothing_match_replay() { + let mut failures = Vec::new(); + for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] { + for case in ["pressure", "locked", "ellipse", "smoothing", "scattering"] { + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + match case { + "locked" => { + s.edit("lock alpha", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + } + "ellipse" => { + s.tools.brush.roundness = 0.2; + s.tools.brush.angle = 37.0; + } + "smoothing" => { + s.tools.brush.smoothing.amount = 0.6; + } + "scattering" => { + s.tools.brush.scattering.enabled = true; + s.tools.brush.scattering.scatter.jitter = 1.0; + } + _ => {} + } + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + s.execute(cmd, params.clone()).unwrap(); + let want = pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + let result = s.execute(cmd, params).unwrap(); + assert_eq!(result["fromLive"], true); + let got = pixels(&s); + let count = got.iter().zip(&want).filter(|(a, b)| a != b).count(); + let max = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + if count > 0 { + failures.push(format!("{cmd} {case} depth={depth}: {count} channels differ, max={max}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn native_modes_selection_and_lock_match_replay() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = setup(depth, mode); + s.edit("lock alpha", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":30,"y":20,"width":110,"height":60})).unwrap(); + let points = [[-10.0, 30.0], [80.0, 50.0], [160.0, 80.0], [60.0, 60.0]]; + for cmd in ["paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge", "paint.smudge"] { + let params = json!({"points":points,"size":30,"hardness":40,"strength":70,"opacity":37}); + let expected_damage = s.execute(cmd, params.clone()).unwrap()["damage"].clone(); + let want = pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + let result = s.execute(cmd, params).unwrap(); + assert_eq!(result["fromLive"], true); + assert_eq!(result["damage"], expected_damage); + assert_eq!(pixels(&s), want, "{mode} {depth} {cmd}"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } +} + +#[test] +fn changed_document_refuses_live_pixels() { + let mut s = setup(8, "rgb"); + let p = json!({"points":[[40,40],[80,60]],"size":30}); + let live = LiveDab::begin(&s, "paint.blur", &p).unwrap(); + s.execute("paint.stroke", json!({"points":[[40,40],[80,60]],"color":"#ffffff","size":20})).unwrap(); + let mut replay = Session::new(); + replay.add_document((*s.active().unwrap().doc).clone(), None); + replay.tools.brush = s.tools.brush.clone(); + s.offer_live_dab(live).unwrap(); + assert!(s.execute("paint.blur", p.clone()).unwrap().get("fromLive").is_none()); + replay.execute("paint.blur", p).unwrap(); + assert_eq!(pixels(&s), pixels(&replay)); +} + +/// An independent paint-level oracle gives Smudge a generously padded working region. Pixels +/// picked up by the initial small dab must not depend on the final stroke's bounding rectangle. +#[test] +fn smudge_pickup_matches_full_neighborhood_when_pressure_expands() { + use photocraft_paint::{ + Stroke, + retouch::{Smudge, apply_dab_stroke}, + }; + + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + let (mut expected, lock) = { + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + (st.doc.layer(id).unwrap().surface().unwrap().clone(), st.doc.effective_locks(id).transparency) + }; + let stroke = Stroke { + brush: photocraft_engine::retouch_cmds::stroke_brush(&s.tools.brush, ¶ms), + points: points.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect(), + }; + let mut effect = Smudge::new(0.8, None, 40.0).with_alpha(Some(3)); + apply_dab_stroke(&mut expected, &stroke, None, lock, 40, |work, fp| effect.dab(work, fp)); + let bounds = s.active().unwrap().doc.bounds(); + let want = expected.read_region(bounds); + s.execute("paint.smudge", params.clone()).unwrap(); + assert!(pixels(&s) == want, "command must read the whole pickup square at depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + let live = LiveDab::begin(&s, "paint.smudge", ¶ms).unwrap(); + s.offer_live_dab(live).unwrap(); + assert_eq!(s.execute("paint.smudge", params).unwrap()["fromLive"], true); + assert!(pixels(&s) == want, "a single batch uses the same pickup square at depth {depth}"); + } +} + +#[test] +fn pressure_sized_live_dabs_commit_only_the_requested_mask_or_channel() { + use photocraft_raster::Surface; + + let fill = |surface: &mut Surface| { + let values: Vec = (0..120).flat_map(|y| (0..200).map(move |x| ((x * 7 + y * 3) % 31) as f32 / 31.0)).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 200, 120), &values); + }; + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on":true})).unwrap(); + s.edit("mask texture", |doc, active| { + fill(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface); + fill(&mut doc.channels[0].surface); + fill(&mut doc.quick_mask.as_mut().unwrap().surface); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + for target in [json!("mask"), json!({"channel":0}), json!("quickMask")] { + for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] { + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + let params = json!({"target":target,"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + s.execute(cmd, params.clone()).unwrap(); + let want = s.active().unwrap().doc.clone(); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + assert_eq!(s.execute(cmd, params).unwrap()["fromLive"], true); + assert_eq!(s.active().unwrap().doc, want, "depth={depth} {cmd} target={target}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + } + } + } +} diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 5d77f2319c..06bafe6cfb 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -340,8 +340,14 @@ pub struct Smudge { } impl Smudge { + /// Radius of the carried-colour buffer, including pixels outside a pressure-sized dab. + /// Both the first pickup and every subsequent pickup need this entire neighbourhood. + pub fn sampling_radius(max_brush_size: f32) -> i32 { + ((max_brush_size / 2.0).ceil() as i32).saturating_add(2) + } + pub fn new(strength: f32, finger: Option>, max_brush_size: f32) -> Self { - Self { strength: strength.clamp(0.0, 1.0), finger, radius: (max_brush_size / 2.0).ceil() as i32 + 2, alpha: None, carry: None } + Self { strength: strength.clamp(0.0, 1.0), finger, radius: Self::sampling_radius(max_brush_size), alpha: None, carry: None } } /// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]). diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index aa57ce2b52..ac88a6c899 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -499,3 +499,59 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); } } + +/// Pressure changes Smudge's painted footprint, but not the square of colour it carries. +/// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. +#[test] +fn pressure_sized_smudge_release_matches_replay_and_undo_redo() { + use crate::stylus::PenSample; + + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size":40,"hardness":0.7,"pressureSize":true,"smoothing":{"amount":0.4}}})); + run("paint.stroke", json!({"points":[[10,20],[190,80]],"size":30,"color":"#2060c0"})); + }; + let mut h = harness(); + h.state_mut().ui.tool = Tool::Smudge; + h.run_steps(1); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + let before = h.state().session.active().unwrap().doc.clone(); + h.state_mut().ui.tool_options.strength = 80.0; + h.run_steps(1); + let pen = |pressure| Some(PenSample { pressure, ..Default::default() }); + h.state().stylus.feed.set(pen(0.1)); + move_to(&mut h, 40.0, 30.0); + button(&mut h, 40.0, 30.0, true); + for (x, y, pressure) in [(65.0, 40.0, 0.4), (100.0, 55.0, 1.0), (140.0, 70.0, 0.7), (60.0, 40.0, 0.2)] { + h.state().stylus.feed.set(pen(pressure)); + move_to(&mut h, x, y); + } + assert!(h.state().live_stroke.is_some()); + button(&mut h, 60.0, 40.0, false); + h.run_steps(2); + assert!(h.state().retouch_from_live, "release must actually reuse the live pixels"); + let (_, params) = h.state().session.journal.iter().rev().find(|(id, _)| id == "paint.smudge").cloned().unwrap(); + let points = params["points"].as_array().unwrap(); + assert!(points.iter().any(|p| p[2].as_f64().unwrap() < 0.2)); + assert!(points.iter().any(|p| p[2].as_f64().unwrap() > 0.9)); + + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width":200,"height":100,"background":"white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + assert_eq!(h.state().session.tools.brush, fresh.tools.brush, "replay uses the Smudge tool's actual brush"); + assert!( + photocraft_compose::render(&before, before.bounds()).px == photocraft_compose::render(&fresh.active().unwrap().doc, before.bounds()).px, + "the replay fixture starts with identical pixels" + ); + assert!(fresh.execute("paint.smudge", params).unwrap().get("fromLive").is_none()); + let committed = h.state().session.active().unwrap().doc.clone(); + let expected = photocraft_compose::render(&fresh.active().unwrap().doc, committed.bounds()); + assert!(photocraft_compose::render(&committed, committed.bounds()).px == expected.px, "live commit and replay must paint identical pixels"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc, before); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc, committed); +} From f4cff2780cafa788bc3d3ce321cacec00b535442 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 19 Oct 2026 14:03:11 -0600 Subject: [PATCH 04/40] test(retouch): name live dab replay regressions by behavior --- .../tests/{pr7281_adversarial.rs => live_dab_matches_replay.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/tests/{pr7281_adversarial.rs => live_dab_matches_replay.rs} (100%) diff --git a/crates/engine/tests/pr7281_adversarial.rs b/crates/engine/tests/live_dab_matches_replay.rs similarity index 100% rename from crates/engine/tests/pr7281_adversarial.rs rename to crates/engine/tests/live_dab_matches_replay.rs From 16d1bc2f1f1e8aa8ac603bbf151244607bd60bbb Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 08:31:29 +0530 Subject: [PATCH 05/40] fix(retouch): carry the Smudge pickup neighborhood through with_effect_on main split LiveDab's constructor into with_effect (session) and with_effect_on (a prepared document copy) after #7848's fix was written, so the cherry-picked fix no longer compiled. with_effect_on keeps its halo signature for the eraser and Pattern Stamp callers; the body moves to with_neighborhood_on, which Smudge's live stroke reaches through with_neighborhood. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/retouch_cmds.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index db47fcacec..eae429f8c0 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -1309,7 +1309,7 @@ impl LiveDab { let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); let doc = (*st.doc).clone(); - let mut live = Self::with_effect_on(doc, cmd, p, stroke, id, halo, effect)?; + let mut live = Self::with_neighborhood_on(doc, cmd, p, stroke, id, neighborhood, effect)?; live.base = Some(base); Ok(live) } @@ -1317,13 +1317,25 @@ impl LiveDab { /// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own /// edit does first (the Background Eraser's Background turned into a normal layer, say). pub(crate) fn with_effect_on( - mut doc: Document, + doc: Document, cmd: &str, p: &Value, stroke: Stroke, id: Option, halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { + Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) + } + + fn with_neighborhood_on( + mut doc: Document, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + neighborhood: DabNeighborhood, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; From 0b3cab2ebac12662e0ce37888b68d0d19f0a2e87 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 03:34:07 +0200 Subject: [PATCH 06/40] CMYK: Dissolve and post-adjustment rounding match the RGB compositor (#9033) The CMYK proptests failed on main now and then (the_split_route_agrees_with_the_exact_path after #8663). Three gaps, each found by the proptests at 5000 cases: - cmyk.rs: a clipped Dissolve layer blended like Normal; it now shows each pixel fully or not at all by the compositor's noise, as the RGB compositor does since #3052. - cmyk.rs: a Dissolve adjustment layer mixed by its opacity; opacity and mask are now the chance a pixel takes the adjustment, as on the RGB compositor (#3050). - native.rs: the display/Flatten ink route didn't round to the document's levels after an adjustment layer, where the RGB compositor and cmyk.rs do; coverage in a pass-through group then differed by up to half a level. The test reference model had the same two Dissolve gaps (ref_clip passed no position, so Dissolve fell back to Normal), which is why random_documents_match_the_reference never caught the first one. Three named regression tests cover the minimal cases. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../proptest-regressions/cmyk_tests.txt | 2 + crates/compose/src/cmyk.rs | 36 +++++- crates/compose/src/cmyk_tests.rs | 103 +++++++++++++++++- crates/compose/src/native.rs | 9 ++ 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/compose/proptest-regressions/cmyk_tests.txt b/crates/compose/proptest-regressions/cmyk_tests.txt index 23a4b6e958..2f3a9c3fb8 100644 --- a/crates/compose/proptest-regressions/cmyk_tests.txt +++ b/crates/compose/proptest-regressions/cmyk_tests.txt @@ -5,3 +5,5 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] } +cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } +cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index da1cb51fdb..52db6582d9 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -402,12 +402,24 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { /// mask; coverage is unchanged (the compositor's adjustment mix). fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { let opacity = l.opacity * l.fill_opacity; - let mv = mask_vals(l, base.rect, cx); + let rect = base.rect; + let w = (rect.width() as usize).max(1); + let mv = mask_vals(l, rect, cx); for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() { let k = opacity * mask_at(&mv, i); if k <= 0.0 { continue; } + if l.blend == BlendMode::Dissolve { + // As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the + // adjustment fully, by the same noise. + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + if super::dissolve_noise(x, y) < k { + *p = [a[0], a[1], a[2], a[3], p[4]]; + } + continue; + } let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); for (v, b) in p.iter_mut().zip(bl) { *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; @@ -416,10 +428,10 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct } /// Rounds to the document's levels after an adjustment layer, as the compositor does (on the -/// channel brightness the document stores, and coverage). -fn quantize_inks(buf: &mut CmykBuffer, q: f32) { +/// channel brightness the document stores, and coverage). Also used by [`super::native`]. +pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) { let round = |v: f32| (v * q + 0.5).floor() / q; - for p in &mut buf.px { + for p in px { for v in p.iter_mut().take(4) { // Whole levels of ink: exactly the stored values, rounding ties as on brightness. *v = (q - ((1.0 - *v) * q + 0.5).floor()) / q; @@ -441,11 +453,23 @@ fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) { let content = layer_content(c, base.rect, cx); let opacity = c.opacity * c.fill_opacity; let gamma = super::text_gamma(c); - for (p, s) in base.px.iter_mut().zip(&content.px) { + let rect = base.rect; + let w = (rect.width() as usize).max(1); + for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() { let alpha = p[4]; if alpha <= 0.0 { continue; } + if c.blend == BlendMode::Dissolve { + // As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes + // the clipped layer fully or not at all, by the same noise. + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + if super::dissolve_noise(x, y) < s[4] * opacity { + *p = [s[0], s[1], s[2], s[3], alpha]; + } + continue; + } let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma); *p = [r[0], r[1], r[2], r[3], alpha]; } @@ -477,7 +501,7 @@ fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) { if base.visible { composite_layer(base, clipped, backdrop, cx); if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) { - quantize_inks(backdrop, q); + quantize_inks(&mut backdrop.px, q); } } } diff --git a/crates/compose/src/cmyk_tests.rs b/crates/compose/src/cmyk_tests.rs index 41232675c0..3e28fccd7e 100644 --- a/crates/compose/src/cmyk_tests.rs +++ b/crates/compose/src/cmyk_tests.rs @@ -219,6 +219,10 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32 a = ref_clip(clipped, a, x, y); } let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); + if l.blend == BlendMode::Dissolve { + // Opacity and mask are the chance the pixel takes the adjustment fully. + return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; + } let mut o = p; for c in 0..4 { o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; @@ -233,7 +237,7 @@ fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka { content = ref_adjusted(c, adj, &[], content, x, y); } else if content[4] > 0.0 { let a = content[4]; - let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, None); + let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y))); content = [r[0], r[1], r[2], r[3], a]; } } @@ -1697,3 +1701,100 @@ fn rgb_colours_become_ink_through_the_document_profile() { assert!(!supports(&overlay) && split::supports(&overlay)); check(&overlay, "colour overlay"); } + +/// The split route and the exact path give the same inks for `s`, alpha included. +fn assert_split_matches_exact(s: &Spec) { + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + assert!(supports(&d) && split::supports(&d)); + let via = split_render(&d); + let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported")); + for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() { + assert!((0..5).all(|c| (g[c] - w[c]).abs() <= 1e-5), "pixel {i}: {g:?} vs {w:?}"); + } +} + +fn raster_spec(blend: BlendMode, fill: (Rect, Cmyka), opacity: f32, clipped: bool) -> LayerSpec { + LayerSpec::Pixels { kind: Kind::Raster, blend, fills: vec![fill], opacity, fill: 1.0, clipped, visible: true, mask: None } +} + +fn group_spec(children: Vec, pass_through: bool) -> LayerSpec { + LayerSpec::Group { children, pass_through, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None } +} + +/// A clipped Dissolve layer shows each pixel fully or not at all on the exact path too, as on the +/// RGB compositor (#9033; it used to mix like Normal: K 0.81 where the noise hides the layer). +#[test] +fn a_clipped_dissolve_layer_dissolves_on_the_exact_path() { + let s = Spec { + w: 4, + h: 5, + layers: vec![group_spec( + vec![ + raster_spec(BlendMode::Normal, (Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), 1.0, false), + raster_spec(BlendMode::Dissolve, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 0.189_737_02, true), + ], + false, + )], + }; + assert_split_matches_exact(&s); +} + +/// A Dissolve adjustment layer takes each pixel fully or not at all on the exact path too (#9033; +/// it used to mix by opacity: cyan 0.267 where the noise picks the adjusted 0.298). +#[test] +fn a_dissolve_adjustment_dissolves_on_the_exact_path() { + let levels = AdjSpec::Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }; + let adjust = LayerSpec::Pixels { + kind: Kind::Adjust(levels), + blend: BlendMode::Dissolve, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 0.897_210_9, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let s = Spec { + w: 3, + h: 2, + layers: vec![ + group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false)], false), + group_spec(vec![adjust], true), + ], + }; + assert_split_matches_exact(&s); +} + +/// After an adjustment layer, the display route ([`crate::native`]) rounds to the document's +/// levels like the export path and the RGB compositor, at any opacity (#9033: in a pass-through +/// group its coverage stayed at 41.32% where the others store 105/255). +#[test] +fn every_cmyk_route_rounds_after_an_adjustment_layer() { + for opacity in [0.0, 0.5] { + let adjust = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Invert), + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let s = Spec { + w: 8, + h: 5, + layers: vec![group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false), adjust], true)], + }; + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + if let LayerContent::Group(g) = &mut d.layers[0].content { + g.children[0].fill_opacity = 0.413_248_66; + } + let rect = Rect::new(0, 0, 1, 1); + let display = crate::render(&d, rect).px[0][3]; + let export = render_cmyk(&d, rect).unwrap_or_else(|| panic!("supported")).px[0][4]; + assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); + } +} diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs index f837842920..98f87f75a9 100644 --- a/crates/compose/src/native.rs +++ b/crates/compose/src/native.rs @@ -214,7 +214,13 @@ impl<'a> InkStack<'a> { } } Item::Adjust(op, opacity, mask) => { + // The result is rounded to the document's levels, as the compositor and the + // export path do after every adjustment layer, at any opacity (#9033). + let quantum = crate::adjustment_quantum(self.depth); if *opacity <= 0.0 { + if let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } continue; } masked(*mask, mv); @@ -228,6 +234,9 @@ impl<'a> InkStack<'a> { d[c] += (v - d[c]) * mix; } } + if let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } } } } From 89312770706f1b0a3177a9a84e4b271639315cb8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 21:52:25 -0400 Subject: [PATCH 07/40] security(io): charge sparse layers, shape caches and shared decodes (#8955) * security(io): charge sparse layers, shape caches and shared decodes ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in. * fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid * io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap --- crates/io/src/abr_map.rs | 12 +++++-- crates/io/src/affinity.rs | 12 ++++++- crates/io/src/kra.rs | 2 +- crates/io/src/kra/tiles.rs | 52 +++++++++++++++++++++++++-- crates/io/src/ora.rs | 22 ++++++++++-- crates/io/src/psd_export.rs | 11 ++++++ crates/io/src/psd_import.rs | 41 +++++++++++++++++++-- crates/io/src/svg.rs | 71 +++++++++++++++++++++++++++++++++++-- 8 files changed, 207 insertions(+), 16 deletions(-) diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 0d3f497c75..a41ba3ac01 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -57,7 +57,7 @@ pub fn map_file(f: &AbrFile, group: &str) -> AbrImport { /// [`map_file`] that checks `ctl` before each brush. pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrupt) -> Result { let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() }; - let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() }; + let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new(), tips: Default::default() }; let total = f.legacy.len().max(f.samples.len()).max(f.presets.len()).max(1) as f32; let step = |i: usize| -> Result<(), photocraft_raster::Cancelled> { ctl.check()?; @@ -255,18 +255,26 @@ struct Mapper<'a> { file: &'a AbrFile, warnings: BTreeSet, unknown: BTreeSet, + /// Tips already converted, by sample: presets can name one sample thousands of times, and a + /// tile clones by sharing its data. + tips: std::collections::HashMap<*const AbrSample, (photocraft_paint::tile::GrayTile, f32)>, } impl Mapper<'_> { /// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side). fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) { + if let Some((tile, size)) = self.tips.get(&std::ptr::from_ref(s)) { + return (TipShape::Sampled(tile.clone()), *size); + } let v = s.to_unit(); let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE); if (w, h) != (s.width, s.height) { self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled")); } let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect(); - (TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32) + let (tile, size) = (GrayTile { width: w, height: h, data }, s.width.max(s.height) as f32); + self.tips.insert(std::ptr::from_ref(s), (tile.clone(), size)); + (TipShape::Sampled(tile), size) } fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic { diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index a5de98a7ef..f794e1be40 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -443,7 +443,17 @@ impl Builder { }) }); let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; - sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); + // The pixel cache is optional (the shape renders from its path without it), so charge the + // area it would cover to the import's pixel budget and go without it when that is spent: + // geometry across a 300000 px canvas would otherwise be rasterized for every shape. + let reach = sh.stroke.as_ref().map_or(0, |s| (s.width.ceil() as i32).saturating_add(1)); + let area = frame.inflate(reach).intersect(&self.canvas); + let cost = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.format.bytes_per_pixel() as u64); + if charge(&mut self.pixel_budget, cost) { + sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); + } else { + self.warn("shapes covering more pixels than the import's memory allows were left without a pixel cache"); + } sh } diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index 64e4facf61..af9b238971 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -112,7 +112,7 @@ impl Reader<'_, '_> { if file.pixel_size != space.pixel_size() { return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size))); } - let cost = file.decoded_bytes(fmt.bytes_per_pixel())?; + let cost = file.decoded_bytes(fmt.bytes_per_pixel(), dx, dy)?; self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?; let mut surface = match self.default_pixel(name, space)? { Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)), diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index 67fd02fca5..d7330868b7 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -216,9 +216,27 @@ impl<'a> TileFile<'a> { Ok(TileFile { width, height, pixel_size, tiles }) } - /// Bytes the decoded tiles take at `bpp` bytes per pixel. - pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result { - self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow")) + /// Bytes the decoded tiles take at `bpp` bytes per pixel: the larger of their own pixels and + /// the whole 256×256 backing tiles they land in (a surface stores no less than a tile, so + /// sparse tiles at distinct positions retain far more than they hold). + pub(crate) fn decoded_bytes(&self, bpp: usize, dx: i32, dy: i32) -> Result { + let overflow = || invalid("tile size overflow"); + let logical = self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?; + let side = photocraft_geom::TILE_SIZE; + let (w, h) = (i32::try_from(self.width.max(1)).map_err(|_| overflow())?, i32::try_from(self.height.max(1)).map_err(|_| overflow())?); + let mut backing = std::collections::HashSet::new(); + for &(x, y, _) in &self.tiles { + // The tile lands at its offset position and covers every backing tile its box overlaps. + let (x, y) = (x.checked_add(dx).ok_or_else(overflow)?, y.checked_add(dy).ok_or_else(overflow)?); + let (x1, y1) = (x.checked_add(w - 1).ok_or_else(overflow)?, y.checked_add(h - 1).ok_or_else(overflow)?); + for c in x.div_euclid(side)..=x1.div_euclid(side) { + for r in y.div_euclid(side)..=y1.div_euclid(side) { + backing.insert((c, r)); + } + } + } + let tile_bytes = (side as usize).checked_mul(side as usize).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?; + Ok(logical.max(backing.len().checked_mul(tile_bytes).ok_or_else(overflow)?)) } /// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`). @@ -260,3 +278,31 @@ impl<'a> TileFile<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn file(positions: &[(i32, i32)]) -> TileFile<'static> { + TileFile { width: 64, height: 64, pixel_size: 4, tiles: positions.iter().map(|&(x, y)| (x, y, &[][..])).collect() } + } + + #[test] + fn sparse_tiles_are_charged_for_the_backing_tiles_they_land_in() { + // Sixteen 64×64 tiles that fill one 256×256 backing tile cost what they hold. + let dense: Vec<_> = (0..4).flat_map(|y| (0..4).map(move |x| (x * 64, y * 64))).collect(); + assert_eq!(file(&dense).decoded_bytes(4, 0, 0).unwrap(), 16 * 64 * 64 * 4); + // The same number far apart each take a whole backing tile: 16 × 256 × 256 × 4. + let sparse: Vec<_> = (0..16).map(|i| (i * 10_240, 0)).collect(); + assert_eq!(file(&sparse).decoded_bytes(4, 0, 0).unwrap(), 16 * 256 * 256 * 4); + } + + #[test] + fn the_placement_offset_and_tile_extent_decide_the_backing_tiles() { + // One 64×64 tile straddling a backing-tile corner once offset touches four. + assert_eq!(file(&[(0, 0)]).decoded_bytes(4, 224, 224).unwrap(), 4 * 256 * 256 * 4); + // A tile wider than a backing tile reaches the ones between its corners. + let wide = TileFile { width: 1024, height: 1, pixel_size: 4, tiles: vec![(0, 0, &[][..])] }; + assert_eq!(wide.decoded_bytes(4, 0, 0).unwrap(), 4 * 256 * 256 * 4); + } +} diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 916ac839a2..193680d31e 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -520,6 +520,20 @@ fn png(w: u32, h: u32, px: &[[f32; 4]], sample: CSample) -> Result> { /// `px` (row-major over `rect`) cut down to the pixels that aren't fully transparent, with /// transparent pixels zeroed, so masked-out or empty margins aren't written. +/// Most bytes of straight RGBA floats one layer may take while it is written. A layer is one PNG +/// of its content's bounding box, so a few pixels far apart make a box of mostly nothing; refuse +/// that rather than allocate it. 1.5 × [`MAX_ENTRY`] of floats is at most 8 B/px × a third of that +/// raw, so the PNG stays within what [`Reader::png`] will read back. +const MAX_EXPORT_LAYER_BYTES: u64 = 3 * MAX_ENTRY as u64 / 2; + +fn admit_layer(rect: Rect) -> Result<()> { + let bytes = u64::from(rect.width()).saturating_mul(u64::from(rect.height())).saturating_mul(std::mem::size_of::<[f32; 4]>() as u64); + if bytes > MAX_EXPORT_LAYER_BYTES { + return Err(IoError::Unsupported(format!("a {}×{} layer is too large to save to OpenRaster", rect.width(), rect.height()))); + } + Ok(()) +} + fn crop(rect: Rect, mut px: Vec<[f32; 4]>) -> (Rect, Vec<[f32; 4]>) { let w = rect.width() as usize; if w == 0 { @@ -609,11 +623,12 @@ impl Writer<'_> { } /// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied. - fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) { + fn pixels(&mut self, layer: &Layer) -> Result<(Rect, Vec<[f32; 4]>)> { let canvas = self.doc.bounds(); let (rect, mut buf) = match &layer.content { LayerContent::Raster(s) => { let r = s.content_bounds(); + admit_layer(r)?; (r, photocraft_compose::surface_to_buffer(s, r).px) } _ => { @@ -627,6 +642,7 @@ impl Writer<'_> { solo.mask = None; solo.effects = Default::default(); let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas); + admit_layer(r)?; (r, photocraft_compose::render_layer(&solo, r).px) } }; @@ -642,7 +658,7 @@ impl Writer<'_> { p[3] *= layer.fill_opacity.clamp(0.0, 1.0); } } - crop(rect, buf) + Ok(crop(rect, buf)) } fn layers(&mut self, layers: &[Layer], depth: usize) -> Result<()> { @@ -671,7 +687,7 @@ impl Writer<'_> { if self.dry { continue; } - let (rect, px) = self.pixels(layer); + let (rect, px) = self.pixels(layer)?; let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) }; let src = format!("data/layer{}.png", self.next); self.next += 1; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index a58e24d524..5fe443748c 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -21,6 +21,10 @@ use crate::psd_import::REGENERATED; const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"]; const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; +/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and +/// the channel planes made from them (or the band parts and the joined buffer) are alive together, +/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`]. +const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 }; const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024; const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; @@ -382,6 +386,13 @@ impl Ex { if r.is_empty() { return (PsdRect::default(), self.empty_channels(), warnings); } + // A layer record is one dense rectangle, so a few pixels far apart make a box of mostly + // nothing, gathered and then copied again. Past the cap the layer is saved empty, said so. + let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64); + if dense > MAX_LAYER_GATHER_BYTES { + warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height())); + return (PsdRect::default(), self.empty_channels(), warnings); + } // The interleaved gather is the sequential pass for full-canvas layers, so it runs // in row bands on all cores and the band results concatenate in order. let row_len = r.width() as usize; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 68cb903e73..717f848716 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -59,6 +59,13 @@ pub(crate) struct Ctx<'a> { pub txt2: Option, /// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id). pub filter_effects: Vec, + /// Filter masks already decoded, by placed id: every layer of a source reads the same item, and + /// a surface clones by sharing its tiles. + pub filter_masks: std::collections::HashMap>, + /// The same for the unfiltered pixels the mask mixes back in. + pub unfiltered: std::collections::HashMap>, + /// Pixel bytes retained in [`Self::unfiltered`], held under [`MAX_UNFILTERED_BYTES`]. + pub unfiltered_bytes: u64, /// Placed layers naming the same source share editable contents, not their placement. pub smart_contents: std::collections::HashMap, /// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file). @@ -278,14 +285,19 @@ impl Ctx<'_> { /// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white /// mask is no mask. fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option { + if let Some(done) = self.filter_masks.get(placed) { + return done.clone(); + } let item = self.filter_effects.iter().find(|i| i.id == placed)?; - match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) { + let mask = match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) { Ok(m) => m, Err(e) => { self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored")); None } - } + }; + self.filter_masks.insert(placed.to_string(), mask.clone()); + mask } fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { @@ -459,7 +471,24 @@ impl Ctx<'_> { let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { (Some(p), Some(m)) => { - let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + let unfiltered = match self.unfiltered.get(&p.placed) { + Some(done) => done.clone(), + None => { + // Every distinct source is kept until the import ends, so what they add up to is + // bounded: past the limit the layer keeps its stored pixels, unmixed. + let item = self.filter_effects.iter().find(|i| i.id == p.placed); + let cost = item + .and_then(|i| i.rect.size().ok()) + .map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64)); + let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES; + let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + if made.is_some() { + self.unfiltered_bytes += cost; + } + self.unfiltered.insert(p.placed.clone(), made.clone()); + made + } + }; match unfiltered { Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), None => stored, @@ -660,6 +689,9 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize { /// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in /// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects, /// masks, metadata, and the fill, type, placed and shape content read before. +/// Most pixel bytes the smart-filter `FEid` sources may keep decoded across one import. +const MAX_UNFILTERED_BYTES: u64 = 1 << 30; + const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [ b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp", b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd", @@ -975,6 +1007,9 @@ pub(crate) fn psd_to_document_decoded_integrity( dpi: doc.resolution_dpi, txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), filter_effects: Vec::new(), + filter_masks: Default::default(), + unfiltered: Default::default(), + unfiltered_bytes: 0, smart_contents: Default::default(), plld_sizes: Default::default(), ctl: *ctl, diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6bc256d4e0..6363945d7e 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -54,15 +54,42 @@ fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> { /// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly, /// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`) /// and its header is malformed, or `None` for other formats. +/// A JPEG's size from its frame header (the first SOF marker), without decoding anything. +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let mut i = 2; + loop { + if *data.get(i)? != 0xFF { + return None; + } + let marker = *data.get(i + 1)?; + match marker { + 0xFF => i += 1, + // SOF0..SOF15, but for DHT (C4), JPG (C8) and DAC (CC). + 0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]); + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]); + return Some((u32::from(w), u32::from(h))); + } + // Start of scan or end of image before any frame header. + 0xD8..=0xDA => return None, + 0x01 | 0xD0..=0xD7 => i += 2, + _ => i += 2 + usize::from(u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?])), + } + } +} + fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option> { match mime { "image/png" => Some(png_dimensions(data)), - "image/webp" => Some(photocraft_codecs::probe_size(data).ok()), + "image/webp" | "image/gif" => Some(photocraft_codecs::probe_size(data).ok()), + "image/jpeg" | "image/jpg" => Some(jpeg_dimensions(data)), "text/plain" => { if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { Some(png_dimensions(data)) - } else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") { + } else if (data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP")) || data.starts_with(b"GIF8") { Some(photocraft_codecs::probe_size(data).ok()) + } else if data.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some(jpeg_dimensions(data)) } else { None } @@ -672,7 +699,25 @@ impl Conv { _ => FillRule::NonZero, }; let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() }; - sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas)); + // The pixel cache is optional, so it is charged like any other retained raster and left + // out (the shape renders from its path) once the budget is spent: compact geometry can + // cover the whole canvas in every one of thousands of shapes. + let b = data.bounds(); + let reach = sh.stroke.as_ref().map_or(0.0, |s| s.width); + let area = Rect::new( + (b.left() - reach).floor() as i32, + (b.top() - reach).floor() as i32, + (b.right() + reach).ceil() as i32, + (b.bottom() + reach).ceil() as i32, + ) + .intersect(&self.canvas); + let pixels = u64::from(area.width()) * u64::from(area.height()); + if self.can_retain_pixels(pixels) { + sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas)); + let bytes = pixels * self.fmt.bytes_per_pixel() as u64; + self.pixel_budget -= bytes; + self.retained_pixel_bytes += bytes; + } Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh))) } @@ -1153,6 +1198,26 @@ mod tests { assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none()); } + #[test] + fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused() { + // SOI, an APP0 segment of length 4, then SOF0 (length 11): 8 bits, height 40000, width 30000. + let jpeg = |h: u16, w: u16| { + let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8]; + v.extend(h.to_be_bytes()); + v.extend(w.to_be_bytes()); + v.extend([1, 1, 0x11, 0]); + v + }; + assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000))); + assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None); + assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None); + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + // 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down. + assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(jpeg(40_000, 30_000)), &opt).is_none()); + } + #[test] fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() { use std::io::Write; From 1c3db2e5c35a4f8c544011e57c8026ccb9741926 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 22:09:29 -0400 Subject: [PATCH 08/40] security(engine): bound script, batch, adjustment, import and export work (#8954) * security(engine): bound script, batch, adjustment, import and export work from file input Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings. * style(engine): rustfmt * engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames --- crates/engine/src/actions_cmds.rs | 14 ++++ crates/engine/src/adjust_cmds.rs | 2 +- crates/engine/src/atn_cmds.rs | 19 +++-- crates/engine/src/atn_cmds/tests.rs | 37 +++++++++ crates/engine/src/automate_cmds.rs | 25 +++++- crates/engine/src/automate_cmds/tests.rs | 43 ++++++++++ crates/engine/src/color_cmds.rs | 3 +- crates/engine/src/commands.rs | 6 +- crates/engine/src/edit_cmds.rs | 2 +- crates/engine/src/file_cmds.rs | 93 ++++++++++++++++------ crates/engine/src/layer_copy_cmds.rs | 2 + crates/engine/src/layer_copy_cmds/tests.rs | 27 +++++++ crates/engine/src/pixels.rs | 78 +++++++++++++++--- crates/engine/src/pixels_tests.rs | 57 ++++++++++++- crates/engine/src/prefs.rs | 22 ++++- crates/engine/src/preset_import_cmds.rs | 9 +++ crates/engine/src/video_cmds.rs | 56 +++++++++++-- crates/engine/src/video_cmds/tests.rs | 11 +++ 18 files changed, 444 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 9f3d74e3e0..7f9b52ff85 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -100,6 +100,12 @@ pub struct ActionState { /// Number of steps executed by the current top-level playback tree. #[serde(skip)] pub playback_steps: u64, + /// How many scripts (`file.scripts.browse`) are running inside one another. + #[serde(skip)] + pub script_depth: u8, + /// Steps run by the current top-level script and everything it calls. + #[serde(skip)] + pub script_steps: u64, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, @@ -150,6 +156,14 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option bool { + // Inside a script the step also counts against the script's total: each top-level playback + // resets its own counter, so a script playing actions in a row would never reach the limit. + if s.actions.script_depth > 0 { + if s.actions.script_steps >= crate::automate_cmds::MAX_SCRIPT_STEPS { + return false; + } + s.actions.script_steps += 1; + } if s.actions.playback_steps >= MAX_PLAYBACK_STEPS { false } else { diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index f4e0694fa3..830d601b5f 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -355,7 +355,7 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option< } else { let mode = surf.format().mode; let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?; - crate::pixels::adjust_surface(&mut out, &adj, None, mode); + crate::pixels::adjust_surface(&mut out, &adj, None, mode).ok()?; } out.prune(); Some(out) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 12b4994feb..6f43e5ef70 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1949,7 +1949,8 @@ fn conditional_from(d: &Descriptor) -> Mapped { return Mapped::Unsupported { harmless: false }; } let branch = |key: &str| match get_ref(d, key).map(target) { - Some(Target::Name(n)) => Some(n), + // Clipped like the stored action names, so the load-time rename map finds it. + Some(Target::Name(n)) => Some(clip_name(&n)), _ => None, }; step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) @@ -2468,7 +2469,7 @@ fn apply_comp_to(_: &str, p: &Value) -> Option { fn play_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { - Some(Target::Name(n)) => step("actions.play", json!({"action": n})), + Some(Target::Name(n)) => step("actions.play", json!({"action": clip_name(&n)})), _ => Mapped::Unsupported { harmless: false }, } } @@ -3877,10 +3878,18 @@ fn normalise(v: &mut Value) { } } +/// Longest action or set name kept (Photoshop's own limit is 255). A file's names are lengths it +/// chooses, and each is copied into every action and every unsupported-step diagnostic. +const MAX_NAME_CHARS: usize = 1024; + +fn clip_name(s: &str) -> String { + s.chars().take(MAX_NAME_CHARS).collect() +} + fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { - let name = a.name.to_string_lossy(); + let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); - action.set = Some(set.to_string()); + action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { let mapped = match (event_for(&item.event), &item.descriptor) { (Some(e), Some(d)) => (e.from)(d), @@ -3914,7 +3923,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) pub fn import(bytes: &[u8]) -> std::result::Result { let file = atn::parse(bytes).map_err(|e| e.to_string())?; let set = file.name.to_string_lossy(); - let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { set }; + let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) }; let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 738892a828..2b967f8c9d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -315,6 +315,25 @@ fn load_renames_each_play_target_once() { assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); } +/// A name over the 1,024-character limit is clipped for the action and for the steps that call it, +/// so a rename still finds its Play target. +#[test] +fn load_renames_play_targets_longer_than_the_name_limit() { + let mut s = Session::new(); + let long = "x".repeat(2000); + let clipped = "x".repeat(1024); + let mut mine = Action::new(clipped.clone(), vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new(long.clone(), vec![]); + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action": long}))]); + let (bytes, _) = export("File", &[&a, &caller], &[], false).unwrap(); + s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + let renamed = format!("{clipped} 2"); + let caller = s.actions.list.iter().find(|a| a.name == "Caller").unwrap(); + assert_eq!(caller.steps[0].1["action"], renamed.as_str()); +} + #[test] fn load_many_duplicate_names_get_sequential_suffixes() { // Several actions share one base name. Deduplication must stay O(n) overall and produce @@ -3082,3 +3101,21 @@ fn saves_round_trip_through_photoshop_events() { let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"path": "/out/result.xcf"}))]); assert!(export("Saves", &[&odd], &[], false).is_err()); } + +#[test] +fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { + let long = "N".repeat(5000); + let unknown = |n: usize| (0..n).map(|_| item("zzzz", "Mystery", None)).collect::>(); + let file = AtnFile { + name: UnicodeString::new_nul(&long), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS); + assert_eq!(imp.actions[0].name.chars().count(), MAX_NAME_CHARS); + assert_eq!(imp.actions[0].set.as_deref().map(|s| s.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(imp.unsupported.len(), 50); + assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 800ff954e0..42fde9e9bf 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -119,12 +119,27 @@ fn check_known(steps: &[(String, Value)], cmd: &str) -> Result<()> { } } +/// Scripts nested in scripts, and steps one top-level script (with everything it calls) may run: +/// a script can name others, so a graph that calls each of the previous ones twice is exponential. +const MAX_SCRIPT_DEPTH: u8 = 16; +pub(crate) const MAX_SCRIPT_STEPS: u64 = crate::actions_cmds::MAX_PLAYBACK_STEPS; + +/// A script run from a script reports its own failure inside an `Ok`; it stops the caller too. +fn nested_script_failure(id: &str, r: &Value) -> Option { + (id == "file.scripts.browse" && r.get("ok") == Some(&Value::Bool(false))).then(|| "a script it runs failed".to_string()) +} + fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { let mut results = Vec::new(); for (id, params) in steps { + s.actions.script_steps = s.actions.script_steps.saturating_add(1); + if s.actions.script_steps > MAX_SCRIPT_STEPS { + results.push(json!({"command": id, "error": format!("the script ran more than {MAX_SCRIPT_STEPS} steps in all (scripts calling scripts)")})); + break; + } match s.execute(id, params.clone()) { // A called action reports a failed step inside an `Ok`; it stops the script too. - Ok(r) => match crate::actions_cmds::nested_failure(id, &r) { + Ok(r) => match crate::actions_cmds::nested_failure(id, &r).or_else(|| nested_script_failure(id, &r)) { Some(error) => { results.push(json!({"command": id, "result": r, "error": error})); break; @@ -156,7 +171,15 @@ fn browse(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, "give \"path\" (a script file), \"script\" (its text) or \"steps\"")), }; check_known(&steps, cmd)?; + if s.actions.script_depth >= MAX_SCRIPT_DEPTH { + return Err(bad(cmd, format!("scripts are nested more than {MAX_SCRIPT_DEPTH} deep"))); + } + if s.actions.script_depth == 0 { + s.actions.script_steps = 0; + } + s.actions.script_depth += 1; let results = run_steps(s, &steps); + s.actions.script_depth -= 1; let ok = results.iter().all(|r| r.get("error").is_none()); Ok(json!({"steps": steps.len(), "ok": ok, "results": results})) } diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 8bea15c69a..3402496331 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -484,3 +484,46 @@ fn load_files_into_stack_aligns_before_the_smart_object() { s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [files[0].clone()], "align": true})).unwrap(); assert_eq!(s.active().unwrap().doc.layers.len(), 1); } + +/// A script file of `lines` (one `file.scripts.browse` call per entry) in `dir`. +fn script_calling(dir: &str, name: &str, callees: &[String]) -> String { + let path = format!("{dir}/{name}.txt"); + let body: String = callees.iter().map(|c| format!("file.scripts.browse {}\n", json!({"path": c}))).collect(); + std::fs::write(&path, body).unwrap(); + path +} + +#[test] +fn scripts_calling_scripts_share_one_step_budget() { + let dir = tmp("fanout"); + // Eight levels, each calling the one below six times: 6^8 (1.7 million) calls of the leaf. + let mut below = script_calling(&dir, "leaf", &[]); + std::fs::write(&below, "select.deselect {}\n").unwrap(); + for level in 0..8 { + below = script_calling(&dir, &format!("level{level}"), &vec![below; 6]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let started = std::time::Instant::now(); + let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap(); + assert_eq!(r["ok"], false, "the budget stops the run: {r}"); + assert!(started.elapsed().as_secs() < 60, "{:?}", started.elapsed()); + // The budget restarts with the next top-level script. + s.execute("file.scripts.browse", json!({"script": "select.deselect {}"})).unwrap(); +} + +#[test] +fn scripts_nested_deeper_than_the_limit_fail() { + let dir = tmp("deep"); + let mut below = script_calling(&dir, "leaf", &[]); + std::fs::write(&below, "select.deselect {}\n").unwrap(); + for level in 0..20 { + below = script_calling(&dir, &format!("level{level}"), &[below]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap(); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains("nested more than"), "{r}"); + assert_eq!(s.actions.script_depth, 0, "the depth is released"); +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 4c8e4df434..9e9c0aafd3 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -590,7 +590,8 @@ pub fn resolve_profile(spec: &str, doc: Option<&Document>, mode: Option = Builtin::ALL.iter().map(|b| b.id()).collect(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 695ee042d5..767a9791b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -469,7 +469,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) let sel = doc.selection.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; - pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()); + pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()).map_err(EngineError::Other)?; Ok(Value::Null) }); } @@ -479,7 +479,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_mask(surf, &adj, sel.as_ref()); + pixels::adjust_mask(surf, &adj, sel.as_ref()).map_err(EngineError::Other)?; } Ok(Value::Null) }); @@ -489,7 +489,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) let sel = doc.selection.clone(); let mode = doc.mode; let surf = paint_surface(doc, id, &Value::Null)?; - pixels::adjust_surface(surf, &adj, sel.as_ref(), mode); + pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?; Ok(()) })?; Ok(Value::Null) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 1a6c3a4072..5ba3a26110 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -715,7 +715,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result { let sel = doc.selection.clone(); let mode = doc.mode; let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; - crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode); + crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?; Ok(()) })?; Ok(Value::Null) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index c058792914..6e81656bdb 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -576,6 +576,17 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { photocraft_compose::flatten_to_surface(doc, fmt, None) } +/// [`flattened`] for documents that came from a file (Save a Copy, Place, Load Files into Stack): +/// a sparse bundle declares its canvas, and one the size of the format allows would be rendered +/// in full, however little it holds. Refused past the compositor's own per-render limit. +pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result { + let b = doc.bounds(); + if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) }); + } + Ok(flattened(doc, fmt)) +} + // ---------- close / revert / save a copy / open as ---------- fn close_all(s: &mut Session) -> Result { @@ -637,7 +648,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. let fmt = doc.pixel_format(); - let px = flattened(&doc, fmt); + let px = flattened_checked(&doc, fmt, "file.saveACopy")?; doc.layers = vec![Layer::new("Background", LayerContent::Raster(px))]; } let warnings = save_doc(&doc, &path, SaveOpts::from_params(s, p, "file.saveACopy")?)?; @@ -744,7 +755,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option (cw / 2.0, ch / 2.0), }; let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round()); - let mut px = flattened(&src, fmt); + let mut px = flattened_checked(&src, fmt, "file.place")?; if (scale - 1.0).abs() > 1e-9 { px = resize_surface(&px, scale, scale, Resample::Bicubic); } @@ -1455,42 +1466,72 @@ fn batch(s: &mut Session, p: &Value) -> Result { list } }; - let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; + // A batch inside a played action charges its inputs × steps (at least one each: an input is read, + // decoded and saved even with no steps) to the playback tree it is in and + // hands the count on, so a batch of batches can't multiply past the limit. A top-level batch + // is not limited by it. + let mut playback_steps = 0; + if s.actions.playing > 0 { + playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64)); + if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() }); + } + s.actions.playback_steps = playback_steps; + } + let actions = crate::actions_cmds::ActionState { + list, + playing: s.actions.playing, + playback_stack: s.actions.playback_stack.clone(), + playback_steps, + ..Default::default() + }; let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; let authorize = s.authorize; + // Each input runs in its own scratch session, so what the played steps spend is carried from + // one input to the next (failed or not) and back to this session, not restarted per input. + let carried = std::cell::Cell::new(playback_steps); + let carried = &carried; let run = move |scratch: &mut Session, out: &str| { scratch.authorize = authorize; scratch.actions = actions.clone(); - // The document as the last recorded save found it, written once the action has run so a - // failing step still writes nothing. - let mut saved: Option> = None; - for (id, params) in &steps { - if batch_save_point(id) { - saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); - continue; + scratch.actions.playback_steps = carried.get(); + let result = (|| { + // The document as the last recorded save found it, written once the action has run so a + // failing step still writes nothing. + let mut saved: Option> = None; + for (id, params) in &steps { + if batch_save_point(id) { + saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + continue; + } + let r = scratch.execute(id, params.clone())?; + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { + return Err(EngineError::Other(error)); + } } - let r = scratch.execute(id, params.clone())?; - // A failed step inside a called action makes this file an error, not a saved result. - if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { - return Err(EngineError::Other(error)); + // While the saved document is still open the steps after the save reach the result too + // (#3058), so it is saved after the action as usual. Closed after its save, or with another + // document left active, it is written as the save found it. + match saved { + Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { + save_doc(&doc, out, save)?; + Ok(true) + } + _ => Ok(false), } - } - // While the saved document is still open the steps after the save reach the result too - // (#3058), so it is saved after the action as usual. Closed after its save, or with another - // document left active, it is written as the save found it. - match saved { - Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { - save_doc(&doc, out, save)?; - Ok(true) - } - _ => Ok(false), - } + })(); + carried.set(scratch.actions.playback_steps); + result }; let r = match names { Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), None => process_tree(s, &inputs, &output, &format, save, "", &run), }; + if s.actions.playing > 0 { + s.actions.playback_steps = carried.get(); + } Ok(r) } @@ -1540,7 +1581,7 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { let fmt = stack.pixel_format(); // First file at the bottom, like Photoshop's script. for (name, d) in &docs { - stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened(d, fmt)))); + stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened_checked(d, fmt, cmd)?))); } let n = stack.layers.len(); let smart = p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index 7995ff6c56..78e72f0a7b 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -222,6 +222,8 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { new.push(id); } *active = above; + // Copied groups keep their own depth: repeated copies would otherwise nest without end. + crate::layer_multi_cmds::check_group_depth(doc, "Copy Layers")?; Ok(new) }); // Nothing changed: the source stays the active document. diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index 93bd7b94ef..b3200b16ef 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -342,3 +342,30 @@ fn a_named_duplicate_takes_the_name_in_one_step() { let r = s.execute("layer.copyToDocument", json!({"document": 1, "name": "Ink"})).unwrap(); assert_eq!(doc(&s, 1).layer(copied(&r)).unwrap().name, "Ink"); } + +#[test] +fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() { + use photocraft_doc::{Layer, MAX_GROUP_DEPTH}; + let (mut s, paint) = two_docs(40, 30, 8); + // A group in the source, and a destination whose active layer sits at the deepest legal level. + s.execute("layer.groupLayers", json!({"layers": [paint.0]})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + s.set_active(1); + let mut inner = None; + s.edit("nest", |doc, active| { + let mut l = Layer::new("deep", LayerContent::Raster(Surface::new(PixelFormat::RGBA8))); + inner = Some(l.id); + for _ in 0..MAX_GROUP_DEPTH { + l = Layer::group("g", vec![l]); + } + doc.layers.push(l); + *active = inner; + Ok(()) + }) + .unwrap(); + s.set_active(0); + let before = doc(&s, 1).clone(); + let e = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [group.0]})).unwrap_err(); + assert!(e.to_string().contains("deeper than"), "{e}"); + assert_eq!(*doc(&s, 1), before, "nothing was copied"); +} diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index dc4e531f53..01339e859d 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -58,20 +58,55 @@ pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::Pix photocraft_compose::composite_to_surface(solo, area, format, None) } +/// Most bytes of tiles an adjustment may materialize beyond the ones a surface stores: two distant +/// tiles make the content bounds a grid of millions of tiles that hold nothing. Written tiles are +/// copies, so the peak is about twice this. +const MAX_UNSTORED_BYTES: u64 = 1 << 30; + +/// Tiles in the grid that covers `r` (one extra per axis for a tile straddling its edge). +fn grid_tiles(r: Rect) -> u64 { + (u64::from(r.width()).div_ceil(TILE_SIZE as u64) + 1) * (u64::from(r.height()).div_ceil(TILE_SIZE as u64) + 1) +} + +/// Bytes the tiles of the grid over `r` take beyond the `stored` ones, in format `fmt`. +fn unstored_bytes(r: Rect, stored: u64, fmt: &photocraft_color::PixelFormat) -> u64 { + grid_tiles(r).saturating_sub(stored).saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64) +} + +/// The tile-sized pieces of `r`, each cut to `r`. +fn tile_rects(r: Rect) -> impl Iterator { + let t = i64::from(TILE_SIZE); + let cols = i64::from(r.x0).div_euclid(t)..=(i64::from(r.x1) - 1).div_euclid(t); + let rows = i64::from(r.y0).div_euclid(t)..=(i64::from(r.y1) - 1).div_euclid(t); + rows.flat_map(move |cy| cols.clone().map(move |cx| Rect::new((cx * t) as i32, (cy * t) as i32, ((cx + 1) * t) as i32, ((cy + 1) * t) as i32).intersect(&r))) +} + +const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds"; + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). /// Works tile by tile in parallel, so a large layer needs one tile's buffers per thread rather /// than four full-layer copies, and the result is the same as one pass over the whole region. -pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) { +pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Result<(), String> { use rayon::prelude::*; let r = s.content_bounds(); if r.is_empty() { - return; + return Ok(()); } let fmt = s.format(); - let done: Vec<_> = s - .take_tiles(r) + // Two distant tiles make the content bounds a grid of millions of tiles that hold nothing. + // Past a budget a transparent default needs only the stored tiles adjusted (the gaps stay + // transparent); an opaque default would change under the adjustment everywhere in that grid, + // so the adjustment is refused rather than left half applied. + let stored: Vec<_> = s.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&r).is_empty()).collect(); + let transparent = fmt.alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0); + let over = unstored_bytes(r, stored.len() as u64, &fmt) > MAX_UNSTORED_BYTES; + if over && !transparent { + return Err(TOO_SPARSE.into()); + } + let taken = if over { stored.into_iter().flat_map(|c| s.take_tiles(c.rect())).collect() } else { s.take_tiles(r) }; + let done: Vec<_> = taken .into_par_iter() .flat_map_iter(|(tc, tile)| { // A one-tile surface, so `adjusted` reads and writes this tile only. @@ -84,6 +119,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf }) .collect(); s.put_tiles(done); + Ok(()) } #[cfg(test)] @@ -96,26 +132,46 @@ mod tests; /// Applies `adj` to a layer mask through the selection. Untouched mask pixels count (they read as /// the mask's default value), and without a selection the default changes too, since a mask /// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780). -pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) { +pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> { let mode = photocraft_color::ColorMode::Grayscale; if let Some(sel) = selection { let r = sel.content_bounds(); - if !r.is_empty() { - let out = adjusted(s, r, adj, Some(sel), mode); - s.write_region(r, &out); + // Tile by tile, and only where the selection stores something (it is unselected, so + // unchanged, elsewhere): its content bounds can span a grid of mostly nothing. + let sparse = sel.default_pixel().iter().all(|v| *v == 0.0); + let tiles: Vec = if sparse { + sel.tiles().map(|(c, _)| c.rect().intersect(&r)).filter(|t| !t.is_empty()).collect() + } else if r.is_empty() { + Vec::new() + } else { + // Unselected pixels count too, so every tile of the span is adjusted, one at a time + // (not one read of the whole span), and the span is bounded by what that writes. + if unstored_bytes(r, 0, &s.format()) > MAX_UNSTORED_BYTES { + return Err(TOO_SPARSE.into()); + } + tile_rects(r).collect() + }; + for t in &tiles { + let out = adjusted(s, *t, adj, Some(sel), mode); + s.write_region(*t, &out); + } + if !tiles.is_empty() { s.prune(); } - return; + return Ok(()); } let fmt = s.format(); let unit = Rect::from_xywh(0, 0, 1, 1); let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode)); - let r = s.tile_bounds(); - if !r.is_empty() { + // Everything the mask doesn't store reads as its default, which `out` already holds adjusted. + // So only the stored tiles are adjusted, not the (possibly huge) rectangle around them. + let stored: Vec = s.tiles().map(|(c, _)| c.rect()).collect(); + for r in stored { out.write_region(r, &adjusted(s, r, adj, None, mode)); } out.prune(); *s = out; + Ok(()) } /// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded. diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 3b1c25c21e..10596fbca9 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -110,7 +110,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() { for sel in [None, Some(&soft)] { let want = one_pass(&s, &adj, sel, fmt.mode); let mut got = s.clone(); - adjust_surface(&mut got, &adj, sel, fmt.mode); + adjust_surface(&mut got, &adj, sel, fmt.mode).unwrap(); assert!(want != s, "{kind} {fmt:?}: the adjustment changes the layer"); assert!(got == want, "{kind} {fmt:?} selection {}: tiled result differs", sel.is_some()); } @@ -121,7 +121,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() { #[test] fn an_empty_layer_is_left_alone() { let mut s = Surface::new(PixelFormat::RGBA8); - adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap(); assert_eq!(s.tile_count(), 0); } @@ -263,7 +263,7 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() { let mut s = Surface::new(fmt); let r = Rect::new(0, 0, 2, 2); s.write_region(r, &(0..4).flat_map(|_| from_rgba(&fmt, [c[0], c[1], c[2], 1.0])).collect::>()); - adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab).unwrap(); let px = s.read_region(Rect::new(1, 1, 2, 2)); let rgba = photocraft_raster::to_rgba(&fmt, &px); let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]); @@ -318,7 +318,7 @@ fn adjusted_pixel(fmt: PixelFormat, ch: &[f32], adj: &Adjustment, k: Option if let Some(k) = k { sel.write_region(unit, &[k]); } - adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode); + adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode).unwrap(); s.read_region(unit) } @@ -404,3 +404,52 @@ fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() { assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label()); } } + +/// Two small blocks two billion pixels apart: the content bounds are a grid of 6·10¹³ tiles +/// that holds nothing, which no adjustment may materialize. +fn distant_blocks(fmt: PixelFormat, default: f32) -> Surface { + let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); + s.fill_rect(Rect::new(0, 0, 10, 10), &vec![0.25; fmt.channels()]); + s.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &vec![0.25; fmt.channels()]); + s +} + +#[test] +fn adjusting_a_layer_of_distant_blocks_visits_only_its_tiles() { + let mut s = distant_blocks(PixelFormat::RGBA8, 0.0); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap(); + let p = s.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + // Straight colour inverted, alpha untouched. + assert!((p[0] - 0.75).abs() < 0.01 && (p[3] - 0.25).abs() < 0.01, "{p:?}"); + assert_eq!(s.tile_count(), 2); +} + +#[test] +fn adjusting_a_mask_of_distant_blocks_visits_only_its_tiles() { + let mut m = distant_blocks(PixelFormat::GRAY8, 1.0); + adjust_mask(&mut m, &Adjustment::Invert, None).unwrap(); + assert!(m.default_pixel()[0] < 0.01, "the default is inverted: {:?}", m.default_pixel()); + let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + assert!((p[0] - 0.75).abs() < 0.01, "{p:?}"); + // Through a selection of its own distant tiles, too. + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &[1.0]); + adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).unwrap(); + let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + assert!((p[0] - 0.25).abs() < 0.01, "{p:?}"); +} + +#[test] +fn an_opaque_layer_of_distant_blocks_is_refused_not_half_adjusted() { + let mut s = distant_blocks(PixelFormat::RGBA8, 1.0); + let before = s.clone(); + assert!(adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).is_err()); + assert!(s == before, "a refused adjustment leaves the layer as it was"); +} + +#[test] +fn a_selection_with_a_nonzero_default_over_distant_blocks_is_refused() { + let mut m = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + let sel = distant_blocks(PixelFormat::GRAY8, 1.0); + assert!(adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).is_err()); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d0d76d0530..6b7639b3ae 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1929,7 +1929,11 @@ impl Session { /// Batches defer colour validation until their complete candidate is staged. Returns /// whether colour settings were touched, including whole-section/root-object updates. fn set_pref(&mut self, path: &str, value: Value, validate_color: bool) -> std::result::Result { - if let Some(rest) = path.strip_prefix("colorSettings") { + // The section itself or a path inside it: `colorSettingsworkingRgb`, `colorSettings.` and + // `colorSettings..workingRgb` are none, and the authorization policy doesn't treat them as one. + if let Some(rest) = + path.strip_prefix("colorSettings").filter(|r| r.is_empty() || r.strip_prefix('.').is_some_and(|c| c.split('.').all(|part| !part.is_empty()))) + { let mut cur = serde_json::to_value(&self.color.settings).map_err(|e| e.to_string())?; let rest = rest.trim_start_matches('.'); if rest.is_empty() { @@ -2735,3 +2739,19 @@ pub fn specs() -> Vec { #[cfg(test)] #[path = "prefs/tests.rs"] mod tests; + +#[cfg(test)] +mod color_settings_path_tests { + use crate::Session; + use serde_json::json; + + #[test] + fn a_path_that_only_starts_with_color_settings_is_not_in_that_section() { + let mut s = Session::new(); + let before = s.color.settings.clone(); + for path in ["colorSettingsworkingRgb", "colorSettings.", "colorSettings..workingRgb"] { + assert!(s.execute("prefs.set", json!({"path": path, "value": "/dev/zero"})).is_err(), "{path}"); + } + assert_eq!(s.color.settings, before); + } +} diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 14273c2bd9..417f5623a1 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -146,11 +146,20 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings })) } +/// Largest canvas (268 MP) a brush may be made from. +const MAX_BRUSH_SOURCE_PIXELS: u64 = 1 << 28; + /// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a /// selection), cropped to the painted extent. The composite is rendered in bands twice: once to /// find the painted bounds, then only those, so a large mostly-white image never needs a /// full-size buffer. fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec, usize, usize)> { + // The whole canvas is rendered (twice) to find the painted part: a sparse native file declares + // its canvas, so refuse one far past any brush source before rendering it. + let canvas = doc.bounds(); + if u64::from(canvas.width()) * u64::from(canvas.height()) > MAX_BRUSH_SOURCE_PIXELS { + return Err(bad(cmd, format!("the image's {}×{} canvas is too large to make a brush from", canvas.width(), canvas.height()))); + } let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0); let mut bounds: Option = None; let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> { diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 7713e0d36f..9917522d06 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -119,6 +119,10 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result { } let fps = doc.timeline.as_ref().map_or(30.0, |t| t.fps); let n = doc.timeline.as_ref().map_or(1, |t| t.duration).max(1); + // A loaded timeline's duration bypasses the validation of timeline.create / setProps. + if n > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!("the timeline is {n} frames; a video layer is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); + } let frames: Vec = (0..n).map(|_| Surface::new(fmt)).collect(); let name = doc.next_layer_name("Video Layer"); let mut l = Layer::raster(name, fmt); @@ -256,6 +260,9 @@ type ImportFrames = fn( &mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>, ) -> std::result::Result; +/// Frame storage one footage import may keep (whole tiles, all frames together). +const MAX_FOOTAGE_BYTES: u64 = 2 << 30; + /// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. @@ -281,8 +288,23 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result = Vec::with_capacity(files.len()); let mut delays = Vec::new(); + // Each frame stores whole tiles however little it draws, so count the tiles kept so far. + let mut kept_bytes = 0u64; + let mut keep = |frames: &mut Vec, surface: Surface| -> std::result::Result<(), String> { + // A frame of fully transparent pixels stores no tiles, so the count needs its own limit. + if frames.len() >= crate::timeline_cmds::MAX_DURATION { + return Err(format!("the footage has more than {} frames", crate::timeline_cmds::MAX_DURATION)); + } + let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * surface.format().bytes_per_pixel() as u64; + kept_bytes = kept_bytes.saturating_add(tile.saturating_mul(surface.tile_count() as u64)); + if kept_bytes > MAX_FOOTAGE_BYTES { + return Err(format!("the footage needs more than {} MiB of frames", MAX_FOOTAGE_BYTES >> 20)); + } + frames.push(surface); + Ok(()) + }; for f in &files { let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?; let name = f.to_string_lossy(); @@ -293,12 +315,19 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result None, }; if let Some(import) = animation { - import(&name, &bytes, &mut |doc, delay_ms| { - frames.push(crate::file_cmds::flattened(&doc, fmt)); + let mut over = None; + let done = import(&name, &bytes, &mut |doc, delay_ms| { + if let Err(msg) = keep(&mut frames, crate::file_cmds::flattened(&doc, fmt)) { + over = Some(msg); + return Err(photocraft_io::IoError::Cancelled); + } delays.push(delay_ms); Ok(()) - }) - .map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?; + }); + if let Some(msg) = over { + return Err(EngineError::Other(format!("`{}`: {msg}", f.display()))); + } + done.map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?; continue; } let mut doc = crate::file_cmds::import(&name, &bytes)?; @@ -306,7 +335,8 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { Ok(json!({"layers": n})) } +/// The document's name without its extension, as a file name: a native document keeps whatever +/// name its file declares, parent-directory components included, and the frames are written +/// beside the chosen folder, not wherever the name leads. +fn safe_stem(name: &str) -> String { + let leaf = name.rsplit(['/', '\\']).next().unwrap_or(name); + let stem = leaf.rsplit_once('.').map_or(leaf, |(a, _)| a).trim(); + if stem.is_empty() || stem.chars().all(|c| c == '.') { "video".to_string() } else { stem.to_string() } +} + /// Export › Render Video: composite the whole document at each timeline frame and write an image /// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec. fn render_video(s: &mut Session, p: &Value) -> Result { @@ -450,7 +489,7 @@ pub fn render_video_with( // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are // valid timelines, so only an unusable loaded rate falls back to 30. let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); - let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); + let stem = safe_stem(&base.name); let fmt = base.pixel_format(); let bounds = base.bounds(); let (w, h) = (bounds.width() as usize, bounds.height() as usize); @@ -495,7 +534,8 @@ pub fn render_video_with( } // Image sequence. - let opts = photocraft_io::ExportOptions::default(); + // PSD / PSB frames would embed the files linked smart objects name: not for a restricted session. + let opts = photocraft_io::ExportOptions { read_linked_files: s.authorize.is_none(), ..Default::default() }; let mut files = Vec::new(); for f in 0..frames { let mut doc = base.clone(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index a3f304ce8e..3560c5f2c3 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -669,3 +669,14 @@ fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() { many.push(20); assert_eq!(fps_from_delays(&many), Some(50.0)); } + +#[test] +fn render_video_names_frames_after_the_leaf_of_the_document_name() { + assert_eq!(safe_stem("clip.mov"), "clip"); + assert_eq!(safe_stem("../../outside/clip.png"), "clip"); + assert_eq!(safe_stem("C:\\Users\\x\\clip.v2.gif"), "clip.v2"); + for hostile in ["..", "../..", "", "/", "a/..", ".hidden."] { + let stem = safe_stem(hostile); + assert!(!stem.contains(['/', '\\']) && !stem.is_empty() && stem.chars().any(|c| c != '.'), "{hostile:?} -> {stem:?}"); + } +} From 47d9266fa1744a1fb0e3e9c5c65494f7e6399f17 Mon Sep 17 00:00:00 2001 From: Ayan <108534604+excited-raccoon@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:13:03 +0500 Subject: [PATCH 09/40] Make Crystallize 4x and Pointillize 3x faster on 24 MP layers (#6200) * Speed up Crystallize and Pointillize with cell-space rasterization * Measure pixelate commands on unlocked layers and Background separately * Record pixel-exact 24 MP filter and command benchmarks --- crates/algo/examples/bench_pixelate.rs | 7 +- crates/algo/src/lib.rs | 52 +- crates/algo/src/pixelate.rs | 13 +- crates/algo/src/pixelate/cells.rs | 571 +++++++++ crates/algo/src/pixelate/tests.rs | 76 ++ crates/engine/examples/bench_cell_filters.rs | 99 ++ docs/pixelate-cell-performance.md | 139 ++ docs/pixelate-cell-timings.json | 1209 ++++++++++++++++++ 8 files changed, 2155 insertions(+), 11 deletions(-) create mode 100644 crates/algo/src/pixelate/cells.rs create mode 100644 crates/engine/examples/bench_cell_filters.rs create mode 100644 docs/pixelate-cell-performance.md create mode 100644 docs/pixelate-cell-timings.json diff --git a/crates/algo/examples/bench_pixelate.rs b/crates/algo/examples/bench_pixelate.rs index a37e8f0775..5c81d5d325 100644 --- a/crates/algo/examples/bench_pixelate.rs +++ b/crates/algo/examples/bench_pixelate.rs @@ -1,5 +1,6 @@ //! Full filter timing, including tile reads, filtering, writeback and pruning. //! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin +//! Also: crystallize, crystallize-large, pointillize, pointillize-large. //! Optional final argument after output.bin: rgb (default), gray, cmyk or lab. //! Run the same example at the base commit and the PR commit, then compare output.bin with cmp. use photocraft_algo::{FilterParams, apply_in}; @@ -29,11 +30,15 @@ fn main() -> Result<(), Box> { _ => return Err("depth must be u8, u16 or f32".into()), }; let params = match case { + "crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 }, + "crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 }, + "pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] }, + "pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] }, "facet" => FilterParams::Facet, "halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] }, "halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] }, "halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] }, - _ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()), + _ => return Err("unknown pixelate benchmark case".into()), }; let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") { "rgb" => ColorMode::Rgb, diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index f804abae51..665f6fd6e9 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1142,16 +1142,43 @@ fn apply_tiled_with_impl( // Tiles finished so far, for progress reported per tile (from any worker thread). let finished = std::sync::atomic::AtomicUsize::new(0); let total = tiles.len().max(1); - let run = |t: &Rect| -> (Rect, Vec) { + enum TileData { + Samples(Vec), + Encoded(Vec), + } + // Cell filters become writeback-bound once their geometry is reused. Encode + // on the tile workers; committing the group then only copies native bytes. + let finish = |data: Vec| { + if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. }) + && let Some(bytes) = pixelate::cells::encode(fmt, &data) + { + TileData::Encoded(bytes) + } else { + TileData::Samples(data) + } + }; + let run = |t: &Rect| -> (Rect, TileData) { // Cancelled: skip the remaining tiles of the group (the result is discarded). if ctl.cancelled() { - return (*t, Vec::new()); + return (*t, TileData::Samples(Vec::new())); } let tick = || { let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; // The last few percent are the writes after each group. ctl.progress(0.98 * n as f32 / total as f32); }; + let sparse = match *params { + FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent), + FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent), + _ => None, + }; + if let Some((src, mut data)) = sparse { + if let Some(sel) = selection { + mix_selection(&mut data, *t, sel, &src, fmt.alpha); + } + tick(); + return (*t, finish(data)); + } let owned; let src = match (&shared, halo) { (Some(s), _) => s, @@ -1180,13 +1207,13 @@ fn apply_tiled_with_impl( _ => Some(kernel(params, src, *t, &ctx)), }, }) else { - return (*t, Vec::new()); + return (*t, TileData::Samples(Vec::new())); }; if let Some(sel) = selection { mix_selection(&mut data, *t, sel, src, fmt.alpha); } tick(); - (*t, data) + (*t, finish(data)) }; // Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each // group is written before the next starts, so a huge layer never holds all its results as @@ -1202,24 +1229,33 @@ fn apply_tiled_with_impl( return None; } #[cfg(not(target_arch = "wasm32"))] - let results: Vec<(Rect, Vec)> = { + let results: Vec<(Rect, TileData)> = { use rayon::prelude::*; chunk.par_iter().map(run).collect() }; #[cfg(target_arch = "wasm32")] - let results: Vec<(Rect, Vec)> = chunk.iter().map(run).collect(); + let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect(); if ctl.cancelled() { return None; } // Checked filters can reject malformed geometry or an allocation failure. // Do not hand an incomplete tile to Surface::write_region. if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. })) - && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) + && results.iter().any(|(t, data)| { + let pixels = t.width() as usize * t.height() as usize; + match data { + TileData::Samples(v) => v.len() != pixels * fmt.channels(), + TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(), + } + }) { return None; } for (t, data) in results { - out.write_region(t, &data); + match data { + TileData::Samples(data) => out.write_region(t, &data), + TileData::Encoded(data) => out.write_interleaved(t, &data), + } } } out.prune(); diff --git a/crates/algo/src/pixelate.rs b/crates/algo/src/pixelate.rs index df6edb5fff..12eb232bb2 100644 --- a/crates/algo/src/pixelate.rs +++ b/crates/algo/src/pixelate.rs @@ -5,6 +5,7 @@ use photocraft_geom::Rect; +pub(crate) mod cells; mod facet; #[cfg(test)] mod reference; @@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, /// being distressed) keeps hard edges and its weight instead of greying where cells straddle /// an edge. A cell whose point falls outside the image takes the (premultiplied) average /// colour of its pixels. -pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { +fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { let n = src.ch; let cell = cell_size.max(2.0); let b = ctx.bounds; @@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType, /// Pointillize: randomly sized dots, coloured from the image at their centre, /// on a canvas of the background colour. -pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { +fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { let n = src.ch; let cell = cell_size.max(2.0); let b = ctx.bounds; @@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see } res } + +pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { + cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed)) +} + +pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { + cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background)) +} diff --git a/crates/algo/src/pixelate/cells.rs b/crates/algo/src/pixelate/cells.rs new file mode 100644 index 0000000000..ebf326fbde --- /dev/null +++ b/crates/algo/src/pixelate/cells.rs @@ -0,0 +1,571 @@ +//! Reuse the jittered lattice in cell space. Raster work stays in the original +//! row/candidate order, including f32 ties and boundary-cell average additions. +use super::*; + +#[derive(Clone, Copy)] +struct Site { + point: (f32, f32), + color: [f32; MAXC], + radius: f32, + sampled: bool, +} + +struct Grid { + x0: i32, + y0: i32, + width: usize, + sites: Vec, +} + +fn key(p: i32, origin: f32, cell: f32) -> i32 { + ((p as f32 + 0.5 - origin) / cell).floor() as i32 +} + +impl Grid { + fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option { + if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? { + return None; + } + Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| { + for (c, value) in color.iter_mut().enumerate() { + *value = src.get(sx, sy, c); + } + src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy) + }) + } + + #[allow(clippy::too_many_arguments)] + fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option { + if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC { + return None; + } + let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32); + let x0 = key(area.x0, origin.0, cell).checked_sub(1)?; + let y0 = key(area.y0, origin.1, cell).checked_sub(1)?; + let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?; + let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?; + let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?; + let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?; + let len = width.checked_mul(height)?; + // Optional tile-local scratch, at most 16 MiB even for a direct kernel call. + if len.checked_mul(std::mem::size_of::())? > 16 * 1024 * 1024 { + return None; + } + let mut sites = Vec::new(); + sites.try_reserve_exact(len).ok()?; + let cc = ncol(ctx, n); + let sub = subtractive(ctx); + for gy in y0..=y1 { + for gx in x0..=x1 { + let point = cell_point(gx, gy, cell, seed, origin); + let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32); + let mut color = [0.0; MAXC]; + let sampled = sample(sx, sy, color.get_mut(..n)?); + let radius = if dots { + let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08; + for value in color.iter_mut().take(cc) { + *value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0); + } + cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed)) + } else { + 0.0 + }; + sites.push(Site { point, color, radius, sampled }); + } + } + Some(Self { x0, y0, width, sites }) + } + + fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> { + let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?; + let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?; + if x.checked_add(2)? >= self.width { + return None; + } + let start = y.checked_mul(self.width)?.checked_add(x)?; + let last = start.checked_add(2 * self.width)?.checked_add(2)?; + self.sites.get(last)?; + Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3)) + } +} + +/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once +/// per column, rather than use an inverse mapping that could round differently. +fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> { + let mut runs: Vec<(i32, i32, i32)> = Vec::new(); + for x in area.x0..area.x1 { + let cx = key(x, origin, cell); + if let Some(last) = runs.last_mut() + && last.0 == cx + { + last.2 = x + 1; + } else { + runs.push((cx, x, x + 1)); + } + } + runs +} + +fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let mut best = (f32::MAX, *neighbours.get(4)?); + for &i in neighbours { + let p = grid.sites.get(i)?.point; + let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2); + if d < best.0 { + best = (d, i); + } + } + Some(best.1) +} + +/// Bounds on the *same f32 score* used by the scalar comparison. Along a +/// scanline, squared x distance has its maximum at an endpoint and its lower +/// bound at the clamped site centre. IEEE subtraction, square, addition and +/// sqrt are monotone on these nonnegative inputs, so these bounds also cover +/// rounding. No estimated bisectors or epsilon decide ownership. +fn score_range(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) { + let left = x0 as f32 + 0.5; + let right = (x1 - 1) as f32 + 0.5; + let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2); + let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2; + let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2; + if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) } +} + +fn score(site: &Site, x: i32, y: i32) -> f32 { + let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2); + if DOTS { site.radius - d.sqrt() } else { -d } +} + +/// Certify a whole run when one candidate's worst score beats every other's +/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the +/// output tile width (at most 11 splits for a 2048-pixel application tile). +fn certified_span(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> { + let len = owners.len(); + if len == 0 { + return Some(()); + } + let x1 = x0.checked_add(i32::try_from(len).ok()?)?; + let middle = x0 + (len / 2) as i32; + let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None); + for &i in ids { + let value = score::(grid.sites.get(i)?, middle, y); + if value > best.0 { + best = (value, Some(i)); + } + } + if let Some(i) = best.1 { + let site = grid.sites.get(i)?; + let lower = score_range::(site, x0, x1, y).0; + let certified = (!DOTS || lower > -0.5) + && ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::(other, x0, x1, y).1)); + if len == 1 || certified { + owners.fill(i as u32); + if DOTS { + if lower >= 0.5 { + depths.fill(0.5); + } else { + for (offset, depth) in depths.iter_mut().enumerate() { + *depth = score::(site, x0 + offset as i32, y); + } + } + } + return Some(()); + } + } else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::(site, x0, x1, y).1 <= -0.5)) { + owners.fill(u32::MAX); + return Some(()); + } + let half = len / 2; + let (left, right) = owners.split_at_mut(half); + let (ld, rd) = depths.split_at_mut(half); + certified_span::(grid, ids, x0, y, left, ld)?; + certified_span::(grid, ids, x0 + half as i32, y, right, rd) +} + +pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option> { + let cell = cell_size.max(2.0); + let n = src.ch; + let b = ctx.bounds; + // Match the scalar averaging window, including floor/ceil at negative origins. + // Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it. + let c0 = ( + (((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?, + (((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?, + ); + let c1 = ( + (((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?, + (((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?, + ); + let win = Rect::new( + b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?, + b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?, + b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?, + b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?, + ) + .intersect(&src.rect) + .intersect(&b); + let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?; + let origin = (b.x0 as f32, b.y0 as f32); + if grid.sites.iter().any(|s| !s.sampled) { + let mut acc = Vec::new(); + let count = grid.sites.len().checked_mul(n.checked_add(1)?)?; + acc.try_reserve_exact(count).ok()?; + acc.resize(count, 0.0f32); + let columns = runs(win, origin.0, cell); + for y in win.y0..win.y1 { + let cy = key(y, origin.1, cell); + for &(cx, x0, x1) in &columns { + let neighbours = grid.neighbours(cx, cy)?; + // An interior cell's average is never used. Skip the entire run if + // all nine possible winners have an on-image colour sample. + if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) { + continue; + } + for x in x0..x1 { + let i = nearest(&grid, &neighbours, x, y)?; + if grid.sites.get(i)?.sampled { + continue; + } + let gx = grid.x0.checked_add((i % grid.width) as i32)?; + let gy = grid.y0.checked_add((i / grid.width) as i32)?; + if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 { + continue; + } + let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?; + let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 }; + for (c, value) in sum.iter_mut().enumerate().take(n) { + let s = src.get(x, y, c); + *value += if ctx.alpha && c + 1 < n { s * alpha } else { s }; + } + *sum.get_mut(n)? += 1.0; + } + } + } + for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) { + let count = *sum.get(n)?; + if !site.sampled && count > 0.0 { + for (dst, value) in site.color.iter_mut().zip(sum).take(n) { + *dst = *value / count; + } + crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha); + site.sampled = true; + } + } + } + crystallize_grid(src, out, ctx, cell, &grid) +} + +fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option> { + let n = src.ch; + let b = ctx.bounds; + let origin = (b.x0 as f32, b.y0 as f32); + let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) }; + let width = out.width() as usize; + let stride = width.checked_mul(n)?; + let columns = runs(out, origin.0, cell); + let mut distances = vec![f32::MAX; width]; + let mut owners = vec![0u32; width]; + for (row, pixels) in result.chunks_exact_mut(stride).enumerate() { + let y = out.y0.checked_add(i32::try_from(row).ok()?)?; + let fy = y as f32 + 0.5; + let cy = key(y, origin.1, cell); + distances.fill(f32::MAX); + for &(cx, x0, x1) in &columns { + let neighbours = grid.neighbours(cx, cy)?; + let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; + if cell >= 32.0 && out.width() <= 2048 { + certified_span::(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?; + continue; + } + owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32); + for &i in &neighbours { + let site = grid.sites.get(i)?; + let dy2 = (site.point.1 - fy).powi(2); + for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { + let fx = (x0 + offset as i32) as f32 + 0.5; + let d = (site.point.0 - fx).powi(2) + dy2; + if d < *distance { + *distance = d; + *owner = i as u32; + } + } + } + } + for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() { + if !b.contains(out.x0 + x as i32, y) { + continue; + } + let site = grid.sites.get(owner as usize)?; + if site.sampled { + pixel.copy_from_slice(site.color.get(..n)?); + } + } + } + Some(result) +} + +/// A site may only compete in its own base column and its two neighbours. +/// Derive the inclusive pixel runs from the exact forward f32 cell mapping. +fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option> { + let columns = runs(out, origin, cell); + (0..grid.width) + .map(|column| { + let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?; + let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1); + let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1); + if lo == hi { + return Some((out.x0, out.x0)); + } + Some((columns.get(lo)?.1, columns.get(hi - 1)?.2)) + }) + .collect() +} + +pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option> { + let cell = cell_size.max(2.0); + // This path uses integer pixel centres and conservative one-pixel support + // margins. Beyond exact half-integer f32 coordinates use the scalar kernel. + if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) { + return None; + } + let grid = Grid::new(src, out, ctx, cell, seed, true)?; + pointillize_grid(src, out, ctx, cell, background, &grid) +} + +fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option> { + let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32); + let bg = native(ctx, background); + let n = src.ch; + let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) }; + let columns = influence_columns(out, origin.0, cell, grid)?; + let base_runs = runs(out, origin.0, cell); + let width = out.width() as usize; + let stride = width.checked_mul(n)?; + let mut depths = vec![-0.5f32; width]; + let mut owners = vec![u32::MAX; width]; + for (row, pixels) in result.chunks_exact_mut(stride).enumerate() { + let y = out.y0.checked_add(i32::try_from(row).ok()?)?; + let fy = y as f32 + 0.5; + let cy = key(y, origin.1, cell); + depths.fill(-0.5); + owners.fill(u32::MAX); + if cell >= 32.0 && out.width() <= 2048 { + for &(cx, x0, x1) in &base_runs { + let ids = grid.neighbours(cx, cy)?; + let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; + certified_span::(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?; + } + } else { + for gy in cy.checked_sub(1)?..=cy.checked_add(1)? { + let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?; + // Global gy/gx order is the original dy/dx tie order at every pixel. + for (column, &(x0, x1)) in columns.iter().enumerate() { + if x0 >= x1 { + continue; + } + let i = row.checked_add(column)?; + let site = grid.sites.get(i)?; + let dy2 = (site.point.1 - fy).powi(2); + let reach = f64::from(site.radius) + 1.5; + let square = reach * reach - f64::from(dy2); + if square < 0.0 { + continue; + } + // The extra pixel covers subtraction/square/sqrt rounding for + // the bounded coordinates/radii above. The exact old depth + // comparison still decides every candidate that survives. + let reach_x = square.max(0.0).sqrt() + 1.0; + let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0); + let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1); + if lo >= hi { + continue; + } + let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?; + for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { + let fx = (lo + offset as i32) as f32 + 0.5; + let d = ((site.point.0 - fx).powi(2) + dy2).sqrt(); + let candidate = site.radius - d; + if candidate > *depth { + *depth = candidate; + *owner = i as u32; + } + } + } + } + } + for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) { + let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 }; + let mut canvas = bg; + if ctx.alpha { + *canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0)); + } + if owner != u32::MAX { + let i = owner as usize; + let k = (depth + 0.5).clamp(0.0, 1.0); + let dot = grid.sites.get(i)?; + for (c, value) in canvas.iter_mut().enumerate().take(n) { + *value += (*dot.color.get(c)? - *value) * k; + } + } + pixel.copy_from_slice(canvas.get(..n)?); + } + } + Some(result) +} + +/// The filters sample cell centres, not a dense neighbourhood. On ordinary +/// tiles read those sparse samples directly; keep only the original output +/// pixels for alpha and selection mixing. Crystallize boundary tiles still +/// use the dense path, whose row-order f32 averages are part of its result. +#[allow(clippy::too_many_arguments)] +pub(crate) fn tile( + surface: &photocraft_raster::Surface, + out: Rect, + ctx: &Ctx, + cell: f32, + seed: u32, + background: Option<[f32; 4]>, + extent: Option, +) -> Option<(Image, Vec)> { + if !cell.is_finite() + || !(2.0..=300.0).contains(&cell) + || out.is_empty() + || out.width() > 2048 + || out.height() > 2048 + || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) + || extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out) + { + return None; + } + let dots = background.is_some(); + let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| { + let (x, y) = match extent { + Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)), + None => (sx, sy), + }; + surface.read_pixel(x, y, color); + ctx.bounds.contains(sx, sy) + })?; + if !dots && grid.sites.iter().any(|s| !s.sampled) { + return None; + } + let original = Image::read(surface, out); + let data = match background { + Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?, + None => crystallize_grid(&original, out, ctx, cell, &grid)?, + }; + Some((original, data)) +} + +/// Same rounding and native byte order as Surface::write_region, before the +/// worker hands its result back. Bounds the allocation and retains float data +/// if an optional encode buffer cannot be reserved. +pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option> { + use photocraft_color::SampleType; + let mut bytes = Vec::new(); + let len = samples.len().checked_mul(format.sample.bytes())?; + bytes.try_reserve_exact(len).ok()?; + bytes.resize(len, 0); + match format.sample { + SampleType::U8 => { + for (dst, value) in bytes.iter_mut().zip(samples) { + *dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + SampleType::U16 => { + for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) { + dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes()); + } + } + SampleType::F32 => { + for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) { + dst.copy_from_slice(&value.to_ne_bytes()); + } + } + } + Some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_raster::Surface; + + #[test] + fn span_certificates_keep_discrete_winners_and_antialias_coverage() { + for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] { + let out = Rect::new(origin, -3, origin + 601, 4); + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + let src = Image::new(out, 4); + for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] { + for seed in 0..32 { + let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap(); + for y in out.y0..out.y1 { + let cy = key(y, ctx.bounds.y0 as f32, cell); + for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) { + let ids = grid.neighbours(cx, cy).unwrap(); + for dots in [false, true] { + let mut owners = vec![u32::MAX; (x1 - x0) as usize]; + let mut depths = vec![-0.5; owners.len()]; + if dots { + certified_span::(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap(); + } else { + certified_span::(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap(); + } + for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() { + let x = x0 + offset as i32; + let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX); + for &i in &ids { + let value = if dots { score::(&grid.sites[i], x, y) } else { score::(&grid.sites[i], x, y) }; + if value > best.0 { + best = (value, i as u32); + } + } + assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}"); + if dots && owner != u32::MAX { + assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits()); + } + } + } + } + } + } + } + } + } + + #[test] + fn worker_encoding_is_surface_write_region_at_every_depth() { + let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY]; + let area = Rect::new(-17, -9, 281, 2); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let format = PixelFormat::new(mode, sample, alpha); + let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect(); + let mut surface = Surface::new(format); + surface.write_region(area, &data); + assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area)); + } + } + } + } + + #[test] + fn optional_grid_rejects_unbounded_or_incomplete_scratch() { + let area = Rect::new(0, 0, 100_000, 100_000); + let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true }; + let image = Image { rect: area, ch: 4, data: Vec::new() }; + assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none()); + assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none()); + } +} diff --git a/crates/algo/src/pixelate/tests.rs b/crates/algo/src/pixelate/tests.rs index c82d747326..f0339e19c9 100644 --- a/crates/algo/src/pixelate/tests.rs +++ b/crates/algo/src/pixelate/tests.rs @@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() { let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() }; assert!(facet::run(&src, out, &ctx).is_none()); } + +#[test] +fn cell_filters_match_scalar_models_edges_and_fractional_cells() { + let bounds = Rect::new(-19, -13, 32, 29); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] { + for alpha in [false, true] { + let ctx = Ctx { bounds, mode, alpha }; + for pattern in 0..5 { + for halo in [0, 7] { + let src = fixture(bounds.inflate(halo), &ctx, pattern); + for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] { + for cell in [2.0, 3.75, 10.0, 100.0, 300.0] { + for seed in [0, 37, u32::MAX] { + let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap(); + assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed)); + for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] { + let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap(); + assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg)); + } + } + } + } + } + } + } + } +} + +#[test] +fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() { + use crate::{FilterParams, apply_tiled}; + let bounds = Rect::new(-17, -9, 86, 78); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let ctx = Ctx { bounds, mode, alpha }; + let src = fixture(bounds, &ctx, 2); + let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha)); + surface.write_region(bounds, &src.data); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect(); + selection.write_region(bounds, &coverage); + for params in [ + FilterParams::Crystallize { cell_size: 3.75, seed: 37 }, + FilterParams::Crystallize { cell_size: 10.0, seed: 37 }, + FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] }, + FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] }, + ] { + for extent in [None, Some(bounds)] { + assert!(matches!(params.halo(), crate::Halo::Radius(_))); + let halo = match params.halo() { + crate::Halo::Radius(r) => r, + _ => 0, + }; + let image = match extent { + Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e), + None => Image::read(&surface, bounds.inflate(halo)), + }; + let mut expected_data = match params { + FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed), + FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background), + _ => Vec::new(), + }; + crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha); + let mut expected = surface.clone(); + expected.write_region(bounds, &expected_data); + for tile in [1, 7, 17, 256] { + let got = apply_tiled(&surface, ¶ms, bounds, bounds, Some(&selection), tile, extent); + assert_bits(&got.read_region(bounds), &expected.read_region(bounds)); + } + } + } + } + } + } +} diff --git a/crates/engine/examples/bench_cell_filters.rs b/crates/engine/examples/bench_cell_filters.rs new file mode 100644 index 0000000000..222d637c5c --- /dev/null +++ b/crates/engine/examples/bench_cell_filters.rs @@ -0,0 +1,99 @@ +//! Time the actual menu command on a 24 MP layer, including history commit. +//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8 +//! Arguments: crystallize|pointillize, cell size, repeats (1..9), depth (8|16|32), layer|background. +//! Run the same example at the base and changed revisions; compare the output hashes. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; +use std::time::Instant; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let case = args.first().map(String::as_str).unwrap_or("crystallize"); + let cell: f32 = args.get(1).map(|v| v.parse()).transpose()?.unwrap_or(10.0); + let reps: usize = args.get(2).map(|v| v.parse()).transpose()?.unwrap_or(5); + let depth: u32 = args.get(3).map(|v| v.parse()).transpose()?.unwrap_or(8); + if !matches!(case, "crystallize" | "pointillize") || !(3.0..=300.0).contains(&cell) || !(1..=9).contains(&reps) { + return Err("case, cell size or repeat count out of range".into()); + } + let kind = args.get(4).map(String::as_str).unwrap_or("layer"); + if !matches!(kind, "layer" | "background") { + return Err("kind must be layer or background".into()); + } + let (w, h) = (6000, 4000); + let bounds = Rect::new(0, 0, w, h); + let sample = match depth { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => return Err("invalid depth".into()), + }; + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + for tile in bounds.tiles() { + let r = tile.rect().intersect(&bounds); + let mut data = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b); + for c in 0..4 { + let value = seed.rotate_left(c * 7).wrapping_add(c * 71) % 65536; + data.push(if c == 3 && kind == "background" { + 1.0 + } else if c == 3 && seed.is_multiple_of(5) { + 0.0 + } else { + value as f32 / 65535.0 + }); + } + } + } + source.write_region(r, &data); + } + let id = format!("filter.pixelate.{case}"); + let mut times = Vec::new(); + let hash = |surface: &Surface| { + let mut hash = blake3::Hasher::new(); + for tile in bounds.tiles() { + hash.update(&surface.to_interleaved(tile.rect().intersect(&bounds))); + } + hash.finalize() + }; + let source_hash = hash(&source); + let mut output_hash = None; + for run in 0..reps { + let mut s = Session::new(); + s.execute("file.new", json!({"width":w,"height":h,"depth":depth,"background":if kind == "background" { "white" } else { "transparent" }}))?; + s.edit("fixture", |doc, _| { + let layer = doc.layers.first_mut().ok_or(photocraft_engine::EngineError::Other("no layer".into()))?; + let surface = layer.surface_mut().ok_or(photocraft_engine::EngineError::Other("no surface".into()))?; + *surface = source.clone(); + Ok(()) + })?; + let start = Instant::now(); + s.execute(&id, json!({"cellSize":cell,"seed":37,"background":[1.0,1.0,1.0,1.0]}))?; + let seconds = start.elapsed().as_secs_f64(); + println!("COMMAND {case} cell={cell} depth={depth} kind={kind} run={} seconds={seconds:.6}", run + 1); + times.push(seconds); + if run + 1 == reps { + let doc = s.active().ok_or("no document")?; + let layer = doc.doc.layers.first().ok_or("no layer")?; + let surface = layer.surface().ok_or("no surface")?; + let filtered_hash = hash(surface); + s.execute("edit.undo", json!({}))?; + let restored = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no undo surface")?; + if hash(restored) != source_hash { + return Err("undo changed source pixels".into()); + } + s.execute("edit.redo", json!({}))?; + let redone = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no redo surface")?; + if hash(redone) != filtered_hash { + return Err("redo changed filtered pixels".into()); + } + output_hash = Some(filtered_hash); + } + } + times.sort_by(f64::total_cmp); + println!("MEDIAN {} HASH {}", times.get(times.len() / 2).ok_or("no runs")?, output_hash.ok_or("no output hash")?); + Ok(()) +} diff --git a/docs/pixelate-cell-performance.md b/docs/pixelate-cell-performance.md new file mode 100644 index 0000000000..5dc7cbe8ed --- /dev/null +++ b/docs/pixelate-cell-performance.md @@ -0,0 +1,139 @@ +# Crystallize and Pointillize performance + +Measured on 2026-10-15, against `4e7a17cd5d5723313f459753663d0d155f047e19`. +The code revision is `3a527eb64e0d0108e44c70dfd200afc0ffd5fe67`. + +## Whole command + +The command bench calls `Session::execute` on a populated 6000 x 4000 RGB layer. +The table below uses 16 workers and 8-bit storage. +The clock includes filtering and the history commit. It excludes document setup, +GUI repaint and presentation. The last run also checks that Undo restores the +source and Redo restores the filtered result. Both checks happen outside the clock. + +| Layer kind | Filter | Cell size | Before | After | Speedup | +|---|---|---:|---:|---:|---:| +| Layer | Crystallize | 10 | 560.8 ms | 137.5 ms | 4.08x | +| Layer | Pointillize | 5 | 430.5 ms | 146.4 ms | 2.94x | +| Layer | Crystallize | 100 | 1387.5 ms | 202.1 ms | 6.87x | +| Layer | Pointillize | 100 | 543.4 ms | 131.9 ms | 4.12x | +| Background | Crystallize | 10 | 891.5 ms | 441.1 ms | 2.02x | +| Background | Pointillize | 5 | 738.5 ms | 608.2 ms | 1.21x | +| Background | Crystallize | 100 | 1708.4 ms | 500.1 ms | 3.42x | +| Background | Pointillize | 100 | 851.4 ms | 439.0 ms | 1.94x | + +Ordinary layers are unlocked and have varying alpha, including zero alpha. +Background layers are opaque and transparency-locked. The existing transparency +restoration pass limits their speedup, especially Pointillize at small cell sizes. +These are command timings, not input-to-display timings. + +## Whole filter + +The filter bench times `apply_in`: reading the input, filtering, converting the +result back to the document depth, writing the tiles and pruning. The table below uses 16 workers. +There is no selection in these timing cases. Correctness tests also cover soft selections. +The fixture has varying alpha, fully transparent pixels and a negative origin. +Every run filters the original surface again; no result cache survives a run. + +| Format | Filter | Cell size | Before | After | Speedup | +|---|---|---:|---:|---:|---:| +| RGB u8 | Crystallize | 10 | 601.6 ms | 137.8 ms | 4.36x | +| RGB u8 | Pointillize | 5 | 449.9 ms | 147.3 ms | 3.05x | +| RGB u8 | Crystallize | 100 | 1340.5 ms | 175.1 ms | 7.65x | +| RGB u8 | Pointillize | 100 | 526.2 ms | 128.1 ms | 4.11x | +| RGB u16 | Crystallize | 10 | 647.3 ms | 214.0 ms | 3.02x | +| RGB u16 | Pointillize | 5 | 502.7 ms | 219.8 ms | 2.29x | +| RGB u16 | Crystallize | 100 | 1413.8 ms | 262.2 ms | 5.39x | +| RGB u16 | Pointillize | 100 | 580.5 ms | 209.3 ms | 2.77x | +| RGB f32 | Crystallize | 10 | 688.4 ms | 362.7 ms | 1.90x | +| RGB f32 | Pointillize | 5 | 551.5 ms | 360.9 ms | 1.53x | +| RGB f32 | Crystallize | 100 | 1495.2 ms | 412.1 ms | 3.63x | +| RGB f32 | Pointillize | 100 | 643.7 ms | 361.6 ms | 1.78x | +| CMYK u16 | Crystallize | 10 | 729.5 ms | 260.2 ms | 2.80x | +| CMYK u16 | Pointillize | 5 | 574.6 ms | 262.2 ms | 2.19x | +| CMYK u16 | Crystallize | 100 | 1560.3 ms | 329.1 ms | 4.74x | +| CMYK u16 | Pointillize | 100 | 675.0 ms | 255.6 ms | 2.64x | +| LAB f32 | Crystallize | 10 | 706.2 ms | 367.6 ms | 1.92x | +| LAB f32 | Pointillize | 5 | 550.4 ms | 361.9 ms | 1.52x | +| LAB f32 | Crystallize | 100 | 1469.7 ms | 404.9 ms | 3.63x | +| LAB f32 | Pointillize | 100 | 647.8 ms | 362.2 ms | 1.79x | +| GRAY u8 | Crystallize | 10 | 396.0 ms | 93.4 ms | 4.24x | +| GRAY u8 | Pointillize | 5 | 332.9 ms | 109.2 ms | 3.05x | +| GRAY u8 | Crystallize | 100 | 1020.5 ms | 105.1 ms | 9.71x | +| GRAY u8 | Pointillize | 100 | 352.9 ms | 76.7 ms | 4.60x | + +All trials and output hashes are in [pixelate-cell-timings.json](pixelate-cell-timings.json). +Filter hashes are SHA-256 of the complete tile-ordered normalized f32 output. +Command hashes are BLAKE3 of the complete tile-ordered native storage bytes. +Fixture generation, output dumps and hashing are outside the measured interval. + +## Where the time went + +Both filters were rebuilding the seeded cell geometry for every pixel and every +neighbour. Pointillize also repeated the dot radius, source lookup and colour +jitter. Those values belong to the cell. The new grid prepares them once per cell +in a tile, reducing that setup work from roughly nine times the pixel count to +the number of cells. + +Crystallize accumulated a premultiplied average for every cell, then usually threw +it away and used the pixel at the cell centre. Only cells whose centres are outside +the image need that average. The new path skips interior averages. Boundary cells +still add their samples in the original row order, with the original f32 rounding. + +Larger cells also allow whole horizontal spans to share a winner. For each site, +the absolute horizontal distance has its maximum at an endpoint. Clamping the site +centre to the span gives a lower bound. The rounded coordinate map is monotone; +squaring nonnegative distances, adding the fixed vertical term and taking a square +root preserve these bounds through the same f32 operations. Subtracting distance +from a fixed dot radius reverses the endpoints for Pointillize. If one site's +worst score beats every other site's best score, it owns the span. Otherwise the span splits. Single pixels retain the original strict +comparison and tie order. A fully covered Pointillize span also has exactly unit +coverage, so it needs no per-pixel square roots. + +The tile path reads sparse cell-centre samples directly from the surface. +Crystallize's boundary tiles retain the dense input for their averages. Extent +clamping and selection mixing follow the original path. + +After these changes, profiling showed a serial tail: converting samples and +writing them back took about 160-180 ms of a roughly 260 ms operation. The two +filters now convert on their existing tile workers. Committing a group copies +encoded bytes. Integer rounding and native byte order are the same as +`Surface::write_region`; float bits are kept. The optional site grid is capped at +16 MiB. Failed grid/average reservations use the scalar kernel; a failed encode +reservation keeps the existing floating-point writeback. + +## Reproduce + +Machine: EC2 c7i.4xlarge, 16 vCPUs (8 cores with SMT), 32 GiB RAM, Intel Xeon +Platinum 8488C, Ubuntu 24.04, rustc 1.99.0. Release profile, thin LTO, one codegen +unit. No `target-cpu` override. Five trials per row; tables use the median. +One-worker rows are in the raw results too. Before and after use separate target +directories so Cargo cannot reuse a library from the other worktree. No builds +ran during the timed trials. + +```sh +RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- crystallize 6000 4000 u8 5 /tmp/crystallize.bin rgb +RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- pointillize-large 6000 4000 u16 5 /tmp/pointillize.bin cmyk +RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8 layer +RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 100 5 8 layer +RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 5 5 8 background +``` + +For the before run, create a worktree at the base above and copy the two benchmark +examples into it. Build with a separate `CARGO_TARGET_DIR` and run there with the same arguments +and thread count. +The `-large` filter cases use cell size 100; the other cases use 10 for Crystallize +and 5 for Pointillize. The engine example takes the size explicitly. + +## Validation + +- 2,447 tests passed across algo and engine; 43 ignored. +- Clippy passed for both crates with all targets and warnings denied. +- Layer checks, wasm checks, scorecard freshness and the quick performance suite passed. +- Scalar comparisons cover RGB, Gray, CMYK, Lab and Multichannel, alpha on/off, + negative origins, fractional cells, boundary averages, HDR and nonfinite samples. +- Storage comparisons cover U8/U16/F32, soft selections, clamped extents and + several tile sizes. The span tests compare discrete winners and coverage, + including coordinates near f32 precision boundaries. +- All 44 measured before/after configurations have identical output hashes. + The 16 command configurations also pass Undo/Redo checks. diff --git a/docs/pixelate-cell-timings.json b/docs/pixelate-cell-timings.json new file mode 100644 index 0000000000..4b9b7e6f01 --- /dev/null +++ b/docs/pixelate-cell-timings.json @@ -0,0 +1,1209 @@ +{ + "base_commit": "4e7a17cd5d5723313f459753663d0d155f047e19", + "optimized_code_commit": "3a527eb64e0d0108e44c70dfd200afc0ffd5fe67", + "date_utc": "2026-10-15 15:41:54.019385+00:00", + "machine": { + "instance_type": "c7i.4xlarge", + "region": "ap-southeast-2", + "vcpus": 16, + "physical_cores": 8, + "ram_gib": 32, + "cpu": "Intel Xeon Platinum 8488C", + "rustc": "rustc 1.99.0 (b940084d7 2026-09-28)", + "profile": "release, thin LTO, codegen-units=1, no target-cpu override" + }, + "filter_results": [ + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.627833, + 0.592312, + 0.574625, + 0.601827, + 0.601559 + ], + "before_sha256": "9729e5b13abbdbcc5d1726774fab2f30278a819d83b2da2a0ca181d41f2f3d90", + "after_seconds": [ + 0.142052, + 0.139163, + 0.136464, + 0.136706, + 0.137844 + ], + "after_sha256": "9729e5b13abbdbcc5d1726774fab2f30278a819d83b2da2a0ca181d41f2f3d90", + "speedup": 4.364056469632338 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.150484, + 0.14732, + 0.146878, + 0.148142, + 0.146824 + ], + "after_sha256": "01a76a253862a0c202d73d7a2dd6e8cf25f7fb2003d25e3a8fac0a371e5ce97c", + "before_seconds": [ + 0.447922, + 0.451246, + 0.449851, + 0.444444, + 0.450445 + ], + "before_sha256": "01a76a253862a0c202d73d7a2dd6e8cf25f7fb2003d25e3a8fac0a371e5ce97c", + "speedup": 3.053563670920445 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.450241, + 1.332886, + 1.396914, + 1.301182, + 1.340506 + ], + "before_sha256": "32030b53c679a585a24f4d8d0dc9ecf0cd6ae28c054a20e6d285a58a3818e47e", + "after_seconds": [ + 0.213627, + 0.182136, + 0.175141, + 0.168722, + 0.169282 + ], + "after_sha256": "32030b53c679a585a24f4d8d0dc9ecf0cd6ae28c054a20e6d285a58a3818e47e", + "speedup": 7.653867455364535 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.141812, + 0.129554, + 0.127641, + 0.128098, + 0.126554 + ], + "after_sha256": "d9fea4dddde3d7c8ad4922e22b7786785fad5ca1aa70996853c746fa6728f279", + "before_seconds": [ + 0.546001, + 0.533609, + 0.518764, + 0.519259, + 0.526173 + ], + "before_sha256": "d9fea4dddde3d7c8ad4922e22b7786785fad5ca1aa70996853c746fa6728f279", + "speedup": 4.107581695264564 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.666095, + 0.636288, + 0.681746, + 0.642546, + 0.647283 + ], + "before_sha256": "06df769faf396a2fefe4356fc2ae00df5cdbc3a46519bbd86be56dd6563ddd39", + "after_seconds": [ + 0.219349, + 0.212478, + 0.214018, + 0.213279, + 0.214794 + ], + "after_sha256": "06df769faf396a2fefe4356fc2ae00df5cdbc3a46519bbd86be56dd6563ddd39", + "speedup": 3.0244325243671093 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.22489, + 0.222843, + 0.219457, + 0.219804, + 0.218159 + ], + "after_sha256": "9c094e3eb56179ff45c4d5020617f97f4c1b3b428d3fac4b563f9d837a17e949", + "before_seconds": [ + 0.502669, + 0.500566, + 0.506185, + 0.510039, + 0.498563 + ], + "before_sha256": "9c094e3eb56179ff45c4d5020617f97f4c1b3b428d3fac4b563f9d837a17e949", + "speedup": 2.2868965077978562 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.525106, + 1.413755, + 1.336072, + 1.4504, + 1.40099 + ], + "before_sha256": "adca99230f78b3de6d90576da243581664246dd5204a2c847407362c55c47e4c", + "after_seconds": [ + 0.290445, + 0.263597, + 0.259109, + 0.241453, + 0.262158 + ], + "after_sha256": "adca99230f78b3de6d90576da243581664246dd5204a2c847407362c55c47e4c", + "speedup": 5.392759328343976 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.220949, + 0.209338, + 0.209718, + 0.207188, + 0.208255 + ], + "after_sha256": "4d19e67bc355afb335bdf60c0a50a79c3b367458c8ebef47c002ff82b36cc615", + "before_seconds": [ + 0.577358, + 0.581319, + 0.589132, + 0.580513, + 0.572729 + ], + "before_sha256": "4d19e67bc355afb335bdf60c0a50a79c3b367458c8ebef47c002ff82b36cc615", + "speedup": 2.773089453419828 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.712932, + 0.68367, + 0.68836, + 0.677215, + 0.701725 + ], + "before_sha256": "dfcdfc5727a92a96f10115587d1c60aee5bb7ec009da1d7e7e7fd287322e0721", + "after_seconds": [ + 0.370395, + 0.358469, + 0.362683, + 0.361822, + 0.366614 + ], + "after_sha256": "dfcdfc5727a92a96f10115587d1c60aee5bb7ec009da1d7e7e7fd287322e0721", + "speedup": 1.8979659923404186 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.367777, + 0.360927, + 0.357517, + 0.358354, + 0.362516 + ], + "after_sha256": "296cf061cd6e153aa6a89d46179590a4cd5375db5666732f37ebc1f848356701", + "before_seconds": [ + 0.561431, + 0.555741, + 0.551515, + 0.544984, + 0.550526 + ], + "before_sha256": "296cf061cd6e153aa6a89d46179590a4cd5375db5666732f37ebc1f848356701", + "speedup": 1.5280513788106735 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.611953, + 1.518048, + 1.438225, + 1.467878, + 1.495212 + ], + "before_sha256": "f8d6ab9b56ee99197364b2f3b3598f228370061270819824aad1ffd2ba0bde1d", + "after_seconds": [ + 0.437999, + 0.412132, + 0.397821, + 0.41565, + 0.407275 + ], + "after_sha256": "f8d6ab9b56ee99197364b2f3b3598f228370061270819824aad1ffd2ba0bde1d", + "speedup": 3.627992973125115 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "rgb", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.380421, + 0.36137, + 0.362779, + 0.358292, + 0.361611 + ], + "after_sha256": "9a767479a45b8f02ded0e66fa858ab0f88cb378649cebc753cab0a291520917b", + "before_seconds": [ + 0.654117, + 0.639867, + 0.643699, + 0.63561, + 0.64916 + ], + "before_sha256": "9a767479a45b8f02ded0e66fa858ab0f88cb378649cebc753cab0a291520917b", + "speedup": 1.7800868889497277 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "cmyk", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.73736, + 0.72951, + 0.728102, + 0.759778, + 0.713682 + ], + "before_sha256": "4f0b16fccf1135061f0623cfa92e9c1e7a29ebcba456a5846a57825c6d4d359d", + "after_seconds": [ + 0.266103, + 0.260226, + 0.2581, + 0.258418, + 0.260419 + ], + "after_sha256": "4f0b16fccf1135061f0623cfa92e9c1e7a29ebcba456a5846a57825c6d4d359d", + "speedup": 2.8033709160498947 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "cmyk", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.269724, + 0.26331, + 0.259774, + 0.262237, + 0.260959 + ], + "after_sha256": "af7ff22e9fded0ebde84c15eb5742acedb1e47add6d3b93f73543eca44d8d9a5", + "before_seconds": [ + 0.576994, + 0.572098, + 0.574641, + 0.577276, + 0.574569 + ], + "before_sha256": "af7ff22e9fded0ebde84c15eb5742acedb1e47add6d3b93f73543eca44d8d9a5", + "speedup": 2.1913040493904368 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "cmyk", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.608209, + 1.570562, + 1.560272, + 1.538092, + 1.504495 + ], + "before_sha256": "94c70a33ecd58b6702a3a05b64e9a720497d79cdf94cdbdea300983d5ef7039a", + "after_seconds": [ + 0.349676, + 0.32396, + 0.317418, + 0.329137, + 0.333118 + ], + "after_sha256": "94c70a33ecd58b6702a3a05b64e9a720497d79cdf94cdbdea300983d5ef7039a", + "speedup": 4.740494079972778 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "u16", + "mode": "cmyk", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.264564, + 0.255588, + 0.255459, + 0.257366, + 0.25319 + ], + "after_sha256": "5ab1948a397096ad80d4dd2e2504285999c484da2444d9283d85d3f999ad9cd6", + "before_seconds": [ + 0.682099, + 0.675, + 0.668598, + 0.671227, + 0.686906 + ], + "before_sha256": "5ab1948a397096ad80d4dd2e2504285999c484da2444d9283d85d3f999ad9cd6", + "speedup": 2.640969059580262 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "lab", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.714891, + 0.7062, + 0.682048, + 0.714372, + 0.696886 + ], + "before_sha256": "dfcdfc5727a92a96f10115587d1c60aee5bb7ec009da1d7e7e7fd287322e0721", + "after_seconds": [ + 0.373375, + 0.368447, + 0.367562, + 0.363684, + 0.366498 + ], + "after_sha256": "dfcdfc5727a92a96f10115587d1c60aee5bb7ec009da1d7e7e7fd287322e0721", + "speedup": 1.9213085139377848 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "lab", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.367976, + 0.361882, + 0.358938, + 0.360809, + 0.362143 + ], + "after_sha256": "8274fbdb002c329c9532d040e8da1e6f27c5dd2ce6967a5e494304a8e191035d", + "before_seconds": [ + 0.555352, + 0.556769, + 0.549227, + 0.550324, + 0.550414 + ], + "before_sha256": "8274fbdb002c329c9532d040e8da1e6f27c5dd2ce6967a5e494304a8e191035d", + "speedup": 1.5209764508873058 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "lab", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.503696, + 1.456232, + 1.433531, + 1.469707, + 1.537525 + ], + "before_sha256": "f8d6ab9b56ee99197364b2f3b3598f228370061270819824aad1ffd2ba0bde1d", + "after_seconds": [ + 0.431285, + 0.403765, + 0.401439, + 0.404902, + 0.406467 + ], + "after_sha256": "f8d6ab9b56ee99197364b2f3b3598f228370061270819824aad1ffd2ba0bde1d", + "speedup": 3.629784491062035 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "f32", + "mode": "lab", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.377728, + 0.362239, + 0.361218, + 0.358815, + 0.364004 + ], + "after_sha256": "637c87e43b7b35b8def6db9d0839203c56e4bcf22ab3990cf171509436aece52", + "before_seconds": [ + 0.65124, + 0.629972, + 0.6478, + 0.65208, + 0.63362 + ], + "before_sha256": "637c87e43b7b35b8def6db9d0839203c56e4bcf22ab3990cf171509436aece52", + "speedup": 1.7883220746523707 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "gray", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 0.443021, + 0.393758, + 0.396, + 0.424204, + 0.389796 + ], + "before_sha256": "2463e69422c59b85dd3a6f4829e4b6ca5bb9002fb49a61f76ea027a95f52e200", + "after_seconds": [ + 0.093682, + 0.093389, + 0.092138, + 0.09363, + 0.09132 + ], + "after_sha256": "2463e69422c59b85dd3a6f4829e4b6ca5bb9002fb49a61f76ea027a95f52e200", + "speedup": 4.240328090032017 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "gray", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.108967, + 0.108478, + 0.109901, + 0.109215, + 0.109563 + ], + "after_sha256": "5160b785b0c442a1022b23c4909d004b5d6f4f2cb91abedae81d166f3ea2df92", + "before_seconds": [ + 0.332933, + 0.333375, + 0.333713, + 0.332056, + 0.332427 + ], + "before_sha256": "5160b785b0c442a1022b23c4909d004b5d6f4f2cb91abedae81d166f3ea2df92", + "speedup": 3.0484182575653525 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "gray", + "threads": 16, + "repeats": 5, + "before_seconds": [ + 1.149491, + 1.01217, + 0.912845, + 1.076414, + 1.020471 + ], + "before_sha256": "617edf53d629f23da405e5c8874b68bfde6d16c1b9d2ee5e5c8b3f6d27d34d5b", + "after_seconds": [ + 0.115332, + 0.109439, + 0.098887, + 0.105079, + 0.101305 + ], + "after_sha256": "617edf53d629f23da405e5c8874b68bfde6d16c1b9d2ee5e5c8b3f6d27d34d5b", + "speedup": 9.71146470750578 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "gray", + "threads": 16, + "repeats": 5, + "after_seconds": [ + 0.082846, + 0.076062, + 0.0767, + 0.076848, + 0.075285 + ], + "after_sha256": "dab961307bc2d433a32f8677545adab57b38892e4de76f87c3095818cdaa6a67", + "before_seconds": [ + 0.352914, + 0.352436, + 0.356243, + 0.349419, + 0.365523 + ], + "before_sha256": "dab961307bc2d433a32f8677545adab57b38892e4de76f87c3095818cdaa6a67", + "speedup": 4.60122555410691 + }, + { + "case": "crystallize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 1, + "repeats": 5, + "before_seconds": [ + 5.555396, + 3.430849, + 3.413242, + 3.410839, + 3.411707 + ], + "before_sha256": "9729e5b13abbdbcc5d1726774fab2f30278a819d83b2da2a0ca181d41f2f3d90", + "after_seconds": [ + 0.749508, + 0.727462, + 0.726931, + 0.725733, + 0.726627 + ], + "after_sha256": "9729e5b13abbdbcc5d1726774fab2f30278a819d83b2da2a0ca181d41f2f3d90", + "speedup": 4.695414007656848 + }, + { + "case": "pointillize", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 1, + "repeats": 5, + "after_seconds": [ + 1.06672, + 1.063805, + 1.064937, + 1.064141, + 1.064508 + ], + "after_sha256": "01a76a253862a0c202d73d7a2dd6e8cf25f7fb2003d25e3a8fac0a371e5ce97c", + "before_seconds": [ + 2.648685, + 2.600713, + 2.560811, + 2.560428, + 2.561348 + ], + "before_sha256": "01a76a253862a0c202d73d7a2dd6e8cf25f7fb2003d25e3a8fac0a371e5ce97c", + "speedup": 2.4061331619865705 + }, + { + "case": "crystallize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 1, + "repeats": 5, + "before_seconds": [ + 11.567562, + 7.667553, + 7.670169, + 7.666155, + 7.671413 + ], + "before_sha256": "32030b53c679a585a24f4d8d0dc9ecf0cd6ae28c054a20e6d285a58a3818e47e", + "after_seconds": [ + 0.839522, + 0.826399, + 0.847257, + 0.844244, + 0.842826 + ], + "after_sha256": "32030b53c679a585a24f4d8d0dc9ecf0cd6ae28c054a20e6d285a58a3818e47e", + "speedup": 9.100536765595745 + }, + { + "case": "pointillize-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "mode": "rgb", + "threads": 1, + "repeats": 5, + "after_seconds": [ + 0.62223, + 0.600445, + 0.598785, + 0.596837, + 0.596572 + ], + "after_sha256": "d9fea4dddde3d7c8ad4922e22b7786785fad5ca1aa70996853c746fa6728f279", + "before_seconds": [ + 2.646821, + 2.578986, + 2.579344, + 2.579339, + 2.58125 + ], + "before_sha256": "d9fea4dddde3d7c8ad4922e22b7786785fad5ca1aa70996853c746fa6728f279", + "speedup": 4.307629616640363 + } + ], + "command_results": [ + { + "case": "crystallize", + "cell_size": 10, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 0.577524, + 0.559449, + 0.56466, + 0.56076, + 0.557744 + ], + "before_blake3": "8411a1c294027229002fc4d8aad5cfac0da43bdb90eca11acd8a73c269203581", + "after_seconds": [ + 0.142803, + 0.137497, + 0.137792, + 0.135462, + 0.136042 + ], + "after_blake3": "8411a1c294027229002fc4d8aad5cfac0da43bdb90eca11acd8a73c269203581", + "speedup": 4.078343527495146 + }, + { + "case": "pointillize", + "cell_size": 5, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 0.432543, + 0.429172, + 0.430488, + 0.431579, + 0.429229 + ], + "before_blake3": "43f190ed6e44c82ba7de4912173ba7bd4026cc79543ae935068e4f46dedad660", + "after_seconds": [ + 0.150243, + 0.146398, + 0.146293, + 0.146287, + 0.147224 + ], + "after_blake3": "43f190ed6e44c82ba7de4912173ba7bd4026cc79543ae935068e4f46dedad660", + "speedup": 2.9405319744805256 + }, + { + "case": "crystallize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 1.504488, + 1.331775, + 1.387534, + 1.342976, + 1.485944 + ], + "before_blake3": "67412d4ef88d0d2f57d7868e40b064aaead0655c1aa5498b2b7ac9163d6199e0", + "after_seconds": [ + 0.220088, + 0.193307, + 0.207379, + 0.191315, + 0.202069 + ], + "after_blake3": "67412d4ef88d0d2f57d7868e40b064aaead0655c1aa5498b2b7ac9163d6199e0", + "speedup": 6.866634664396815 + }, + { + "case": "pointillize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 0.530853, + 0.543471, + 0.543449, + 0.53623, + 0.550022 + ], + "before_blake3": "2d924cd8a2f8fac84e0b10888cece754f195c66b4de606dbe1df51507ce55f02", + "after_seconds": [ + 0.144178, + 0.13188, + 0.133034, + 0.131368, + 0.129626 + ], + "after_blake3": "2d924cd8a2f8fac84e0b10888cece754f195c66b4de606dbe1df51507ce55f02", + "speedup": 4.120784046102517 + }, + { + "case": "crystallize", + "cell_size": 10, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 0.912359, + 0.862401, + 0.89186, + 0.851327, + 0.891503 + ], + "before_blake3": "9fd831868a65097a57fd13a2145a513aae2e4e167c3afbd43cb7f6fca7f2f59d", + "after_seconds": [ + 0.441443, + 0.441112, + 0.438972, + 0.437652, + 0.441879 + ], + "after_blake3": "9fd831868a65097a57fd13a2145a513aae2e4e167c3afbd43cb7f6fca7f2f59d", + "speedup": 2.0210354739839316 + }, + { + "case": "pointillize", + "cell_size": 5, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 0.743084, + 0.734156, + 0.738533, + 0.734801, + 0.742792 + ], + "before_blake3": "1503b4262dc507b71fc8af1d75aa62da1545cd6e946127834f8eb852b3dc00b8", + "after_seconds": [ + 0.615126, + 0.610135, + 0.607093, + 0.606376, + 0.6082 + ], + "after_blake3": "1503b4262dc507b71fc8af1d75aa62da1545cd6e946127834f8eb852b3dc00b8", + "speedup": 1.2142929957250905 + }, + { + "case": "crystallize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 1.783782, + 1.708372, + 1.60962, + 1.743239, + 1.657798 + ], + "before_blake3": "0d6c8921724d3168e106268b77dce27133ac797be0992ec46e67bcfb02a35cc4", + "after_seconds": [ + 0.521013, + 0.500095, + 0.47914, + 0.492585, + 0.50148 + ], + "after_blake3": "0d6c8921724d3168e106268b77dce27133ac797be0992ec46e67bcfb02a35cc4", + "speedup": 3.4160949419610276 + }, + { + "case": "pointillize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 16, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 0.861252, + 0.848191, + 0.858539, + 0.851327, + 0.851363 + ], + "before_blake3": "4ad27a164daa3546f9b3169a12fa859327cf0a515c85a3f1dee06aa359eadb6d", + "after_seconds": [ + 0.452509, + 0.43853, + 0.437391, + 0.438981, + 0.441354 + ], + "after_blake3": "4ad27a164daa3546f9b3169a12fa859327cf0a515c85a3f1dee06aa359eadb6d", + "speedup": 1.939407400320287 + }, + { + "case": "crystallize", + "cell_size": 10, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 5.673624, + 3.492806, + 3.475708, + 3.477877, + 3.469182 + ], + "before_blake3": "8411a1c294027229002fc4d8aad5cfac0da43bdb90eca11acd8a73c269203581", + "after_seconds": [ + 0.765996, + 0.744955, + 0.749353, + 0.75142, + 0.750839 + ], + "after_blake3": "8411a1c294027229002fc4d8aad5cfac0da43bdb90eca11acd8a73c269203581", + "speedup": 4.631987683111825 + }, + { + "case": "pointillize", + "cell_size": 5, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 2.699748, + 2.747147, + 2.722149, + 2.707603, + 2.715638 + ], + "before_blake3": "43f190ed6e44c82ba7de4912173ba7bd4026cc79543ae935068e4f46dedad660", + "after_seconds": [ + 1.123413, + 1.121467, + 1.114226, + 1.119612, + 1.122298 + ], + "after_blake3": "43f190ed6e44c82ba7de4912173ba7bd4026cc79543ae935068e4f46dedad660", + "speedup": 2.421505046514967 + }, + { + "case": "crystallize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 12.371182, + 8.242147, + 8.270612, + 8.225121, + 8.229577 + ], + "before_blake3": "67412d4ef88d0d2f57d7868e40b064aaead0655c1aa5498b2b7ac9163d6199e0", + "after_seconds": [ + 0.921399, + 0.901025, + 0.931026, + 0.927573, + 0.930186 + ], + "after_blake3": "67412d4ef88d0d2f57d7868e40b064aaead0655c1aa5498b2b7ac9163d6199e0", + "speedup": 8.885712499177961 + }, + { + "case": "pointillize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "layer", + "before_seconds": [ + 2.839682, + 2.761838, + 2.767583, + 2.778351, + 2.771421 + ], + "before_blake3": "2d924cd8a2f8fac84e0b10888cece754f195c66b4de606dbe1df51507ce55f02", + "after_seconds": [ + 0.66096, + 0.635965, + 0.636808, + 0.636474, + 0.637307 + ], + "after_blake3": "2d924cd8a2f8fac84e0b10888cece754f195c66b4de606dbe1df51507ce55f02", + "speedup": 4.352051167698898 + }, + { + "case": "crystallize", + "cell_size": 10, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 6.180668, + 3.944473, + 3.907528, + 3.895279, + 3.89935 + ], + "before_blake3": "9fd831868a65097a57fd13a2145a513aae2e4e167c3afbd43cb7f6fca7f2f59d", + "after_seconds": [ + 1.114765, + 1.091509, + 1.093025, + 1.087055, + 1.087061 + ], + "after_blake3": "9fd831868a65097a57fd13a2145a513aae2e4e167c3afbd43cb7f6fca7f2f59d", + "speedup": 3.5799320023930172 + }, + { + "case": "pointillize", + "cell_size": 5, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 3.10087, + 3.055147, + 3.012262, + 3.015652, + 3.010304 + ], + "before_blake3": "1503b4262dc507b71fc8af1d75aa62da1545cd6e946127834f8eb852b3dc00b8", + "after_seconds": [ + 1.586816, + 1.586988, + 1.590551, + 1.584803, + 1.588651 + ], + "after_blake3": "1503b4262dc507b71fc8af1d75aa62da1545cd6e946127834f8eb852b3dc00b8", + "speedup": 1.9002361706578752 + }, + { + "case": "crystallize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 12.682191, + 8.52726, + 8.550486, + 8.562125, + 8.565027 + ], + "before_blake3": "0d6c8921724d3168e106268b77dce27133ac797be0992ec46e67bcfb02a35cc4", + "after_seconds": [ + 1.24557, + 1.219484, + 1.251882, + 1.251629, + 1.24961 + ], + "after_blake3": "0d6c8921724d3168e106268b77dce27133ac797be0992ec46e67bcfb02a35cc4", + "speedup": 6.851837773385296 + }, + { + "case": "pointillize", + "cell_size": 100, + "width": 6000, + "height": 4000, + "depth": 8, + "mode": "rgb", + "threads": 1, + "repeats": 5, + "layer_kind": "background", + "before_seconds": [ + 3.176527, + 3.111984, + 3.102162, + 3.113826, + 3.111012 + ], + "before_blake3": "4ad27a164daa3546f9b3169a12fa859327cf0a515c85a3f1dee06aa359eadb6d", + "after_seconds": [ + 0.985622, + 0.963362, + 0.95764, + 0.956854, + 0.954553 + ], + "after_blake3": "4ad27a164daa3546f9b3169a12fa859327cf0a515c85a3f1dee06aa359eadb6d", + "speedup": 3.2496386951255167 + } + ], + "executable_sha256": { + "before": "9e252a4b7e9c0bf667f2c84ecee8d8c279c7c5d4998a9f182e7654b421372329", + "after": "584828da7d53e7cf160e121c64ba0db54f29c1983f11865e4ee77427f6f1d870", + "command-before": "9581347703dd4acc4a35d045d15a11cd932dfa70bc54b99689e8fc4ae953ed73", + "command-after": "d4e94d310eb51a65f54bac0dd360cf8dc78a40bb3ad9349a1270719e07512119" + }, + "measurement_notes": [ + "Fresh source surface per trial; no filtered result reuse.", + "Command clock covers Session.execute including history commit; excludes setup, GUI repaint and presentation.", + "Undo and Redo checked outside command clock for each before/after configuration.", + "Filter output hashing/dumps and fixture construction are outside the clock.", + "Before and after command executables were built in separate target directories.", + "No builds ran concurrently with timed trials." + ] +} From b07b7fab559ce2f7c3ba7ef80b7fb50c3aad170e Mon Sep 17 00:00:00 2001 From: ebosido444-ai Date: Sat, 24 Oct 2026 18:14:00 +0300 Subject: [PATCH 10/40] =?UTF-8?q?perf(seam):=20the=20shrink=20proxy=20drop?= =?UTF-8?q?s=20its=20original-column=20map,=20same=20pixels=20(=E2=88=9215?= =?UTF-8?q?=20%=20at=2024=20MP)=20(#8526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(seam): drop the original-column map from the shrink proxy, same pixels (-15 % at 24 MP) Content-Aware Scale's proxy seam search tracked each panel column's original column in an idx map, one usize per proxy pixel, shifted by every seam. The original column is x plus the number of columns already taken at or left of it, so a per-row list of the taken columns answers the same question without a buffer the size of the energy map. The recorded P28 budget number predates the proxies: the 2026-10-05 baseline commit carves at full height and recomputes energy per seam, and measures 266 s for the 24 MP case on the machine this was timed on, against 2.6 s now. Documented in docs/seam-carving-performance.md; enforce stays off until a budgeted-class run can vouch for the whole row. * review: generate exactly ch samples per pixel in bench_seam, reject ch > 4 The fixture flattened four samples per pixel and then truncated to w*h*ch, so a three-channel run grouped samples across pixel boundaries and any ch above four built a buffer shorter than the rows carve_width indexes. Generate ch samples inside the per-pixel closure and refuse ch outside 1..=4. The three-channel row is dropped from the perf note rather than re-measured here: it moved inside the timer noise either way, and the other agents' builds on this machine make a fresh number worthless. The four-channel hashes are unchanged, which is the check that this edit touched only the fixture. * perf(seam): find the removed column by binary search, not a linear scan Recording a seam recovered each removed original column by scanning the row's list of already-taken columns, so a deep shrink paid a comparison per removed column on every row and every seam: quadratic in the seam count, and worse than the index map this PR replaced once a shrink got deep enough. `o - i` never decreases along the row's sorted list, so that count is a binary search, and the original column belongs where the search stopped; the rows are independent and update in parallel, like the pixel shift. What is left per removed column is one insert. Output is unchanged: same columns, same order, same hashes (10 % and 50 % width cases below), and `counted_removed_columns_match_the_tracked_map` now runs a tracking and a counting carver side by side to one column and compares every row's list after every seam. docs/seam-carving-performance.md gains those cases, states that the ~104x figure is the two example runs rather than the baseline row, and names perf/baseline.json as the source of the 954 s. --------- Co-authored-by: ebosido444-ai --- crates/algo/examples/bench_seam.rs | 32 +++++++++ crates/algo/src/seam.rs | 74 +++++++++++++++++---- docs/seam-carving-performance.md | 100 +++++++++++++++++++++++++++++ perf/budgets.toml | 4 +- 4 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 crates/algo/examples/bench_seam.rs create mode 100644 docs/seam-carving-performance.md diff --git a/crates/algo/examples/bench_seam.rs b/crates/algo/examples/bench_seam.rs new file mode 100644 index 0000000000..50225c4b50 --- /dev/null +++ b/crates/algo/examples/bench_seam.rs @@ -0,0 +1,32 @@ +//! Timing and output hash for Content-Aware Scale's seam carving core (P28). +//! +//! `cargo run --release -p photocraft-algo --example bench_seam [width height new_width channels]` +//! +//! Prints the carve time and an FNV-1a hash of the output's raw f32 bits, so two builds can be +//! compared with `cmp`-level strictness without writing tens of megabytes of pixels to disk. +use std::time::Instant; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let num = |i: usize, d: usize| a.get(i).and_then(|s| s.parse().ok()).unwrap_or(d); + let (w, h, new_w, ch) = (num(0, 6000), num(1, 4000), num(2, 5400), num(3, 4)); + if !(1..=4).contains(&ch) { + eprintln!("channels must be 1..=4"); + std::process::exit(2); + } + // Deterministic photo-ish content: gradients with fine detail, so the energy map carries + // structure instead of one flat value per row. + let img: Vec = (0..w * h) + .flat_map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let d = ((x * 0.13).sin() * (y * 0.07).cos()).abs() * 0.3; + [(x / w as f32) * 0.6 + d, (y / h as f32) * 0.9 - d, d, 1.0].into_iter().take(ch) + }) + .collect(); + let t = Instant::now(); + let out = photocraft_algo::seam::carve_width(w, h, ch, &img, None, new_w); + let ms = t.elapsed().as_secs_f64() * 1e3; + let hash = + out.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, v| v.to_bits().to_le_bytes().iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3))); + println!("{w}x{h}x{ch} -> {new_w}: {ms:.1} ms, {} samples, fnv {hash:016x}", out.len()); +} diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 97619c7406..0cbd9a66a2 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -359,19 +359,18 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: }); let seam_count = w - new_w; - let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true); - let mut removed = Vec::with_capacity(seam_count); + // Removing a seam shifts the panel left, so a panel column is not an original one. Tracking + // the original column per pixel costs a third buffer that every seam has to shift (24 MB a + // seam at 24 MP); counting the columns already taken from that row is exact and costs a + // binary search per removed column. + let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false); + let mut removed_by_row: Vec> = vec![Vec::new(); proxy_h]; + let mut taken = 0usize; while carver.cw > new_w { ctl.check()?; - removed.push(carver.remove_seam()); - ctl.progress(removed.len() as f32 / seam_count as f32); - } - - let mut removed_by_row = Vec::with_capacity(proxy_h); - for proxy_y in 0..proxy_h { - let mut columns: Vec = removed.iter().map(|path| path[proxy_y]).collect(); - columns.sort_unstable(); - removed_by_row.push(columns); + record_removed(&mut removed_by_row, &carver.remove_seam()); + taken += 1; + ctl.progress(taken as f32 / seam_count as f32); } let mut out = Vec::with_capacity(new_w * h * ch); let mut columns = vec![0; seam_count]; @@ -403,6 +402,32 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: Ok(out) } +/// Add one seam, given in panel columns, to each row's sorted list of removed original columns. +/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and +/// that never decreases along the list, so the taken columns at or left of panel column `x` are a +/// prefix found by binary search. `x` plus that prefix's length is the original column, and every +/// removal the search passed is left of it, so it inserts where the search stopped. The rows are +/// independent, so they update in parallel like the pixel shift. +fn record_removed(removed_by_row: &mut [Vec], seam: &[usize]) { + // The entries are the original columns still taken from one row, sorted, so entry `i` is at + // least `i` and `o - i` cannot underflow. + let add = |(row, &x): (&mut Vec, &usize)| { + let (mut lo, mut hi) = (0, row.len()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid } + } + row.insert(lo, x + lo); + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add); + } + #[cfg(target_arch = "wasm32")] + removed_by_row.iter_mut().zip(seam.iter()).for_each(add); +} + /// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing). pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec { // Never cancelled; the fallback is unreachable. @@ -761,6 +786,33 @@ mod tests { assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled)); } + /// The proxy path counts each removed original column instead of shifting an index map, so a + /// tracking and a counting carver must agree: run both side by side down to one column and + /// check every row's list after every seam (tie-heavy noise gives rows different histories; + /// 1-4 channels, with and without protection). + #[test] + fn counted_removed_columns_match_the_tracked_map() { + let cases: [(usize, usize, usize, u32, bool); 5] = + [(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)]; + for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() { + let img = noise(w * h * ch, levels, 31 + i as u32); + let protect = prot.then(|| noise(w * h, 3, 77 + i as u32)); + let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true); + let mut counted = Carver::new(w, h, ch, img, protect, false); + let mut expected: Vec> = vec![Vec::new(); h]; + let mut got: Vec> = vec![Vec::new(); h]; + while tracked.cw > 1 { + let seam = tracked.remove_seam(); + for (row, &x) in expected.iter_mut().zip(seam.iter()) { + let at = row.partition_point(|&o| o < x); + row.insert(at, x); + } + record_removed(&mut got, &counted.remove_seam()); + assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw); + } + } + } + #[test] fn enlarging_duplicates_flat_areas() { let (w, h) = (30, 8); diff --git a/docs/seam-carving-performance.md b/docs/seam-carving-performance.md new file mode 100644 index 0000000000..77fc736264 --- /dev/null +++ b/docs/seam-carving-performance.md @@ -0,0 +1,100 @@ +# Content-Aware Scale performance + +2026-10-22. Two things are recorded here: what this change saves, and a correction to the P28 row's +recorded number, which predates the seam-search proxies and reads as hundreds of times worse than +the code does today. + +## The change + +Content-Aware Scale shrinks an image by removing one seam at a time. On large images the seams are +searched on a vertically averaged copy (`carve_width_proxy_height`), and a panel column found there +is not an original column: each seam already removed has shifted the panel left. That was tracked +with an `idx` map — one `usize` per proxy pixel, built up front and shifted by every seam. + +The original column is recoverable without the map: for the `i`-th column already taken from a row, +`o - i` is the panel column it occupied, so the taken columns at or left of panel column `x` are +those with `o - i <= x`, and the original column is `x` plus that count. `o - i` never decreases +along the row's list, so that count is a binary search and the original column inserts where the +search stopped; the rows are independent and update in parallel, like the pixel shift. + +Counting it by scanning the row's list instead is a comparison per already-removed column, so its +cost grows with the square of the seam count and a deep shrink pays it on every row and every seam. +The binary search removes that term; what is left per removed column is one insert into a list of at +most `seam_count` entries, i.e. an element move rather than a comparison. + +Output is unchanged: the same original columns reach the assembly pass, in the same order, so every +pixel written is the pixel the map produced. The 4-channel cases below hash identically (FNV-1a over +the output's raw `f32` bits); the 3-channel case is unchanged within timer noise. + +## Timings + +Windows 11, Intel i5-10300H (4 cores / 8 threads), rustc 1.98.1, `--release`, default rayon pool. +The timer covers `seam::carve_width` only: document read, tile writeback, history and the undo that +follows the command are outside it. Each cell is the same binary run before and after the change, in +the same session, nothing else running. + +| Case | Before (ms) | After (ms) | Change | Output | +|---|---:|---:|---:|---| +| 4000 × 3000 → 3600, 4 ch | 1424.7 | 1132.9 | −20.5 % | identical | +| 6000 × 4000 → 5400, 4 ch | 3009.3 | 2559.4 | −15.0 % | identical | +| 7200 × 5000 → 6480, 4 ch | 4352.8 | 3766.9 | −13.5 % | identical | + +Deep shrinks, where the counting scan was quadratic, on the same machine and the same binary with +only the recording swapped (three alternating runs per side, `min`/`median`): + +| Case | Scan (ms) | Binary search (ms) | Change | Output | +|---|---:|---:|---:|---| +| 6000 × 4000 → 600, 4 ch (5 400 seams) | 16 640 / 16 757 | 13 915 / 14 066 | −16 % | identical, fnv `0f7eaa6af3cf1557` | +| 6000 × 4000 → 3000, 4 ch (3 000 seams) | 10 617 / 10 814 | 9 936 / 10 012 | −6 % | identical, fnv `1780ee9bc9077ee9` | + +The element move that remains is still linear in the row's list length, so a shrink this deep is not +down to the index map's cost yet on four cores; a rank bitvector over the row would remove it, and is +not in this change. + +The map was 24 MB next to a 36 MB pixel buffer at 24 MP, so the saving tracks how many bytes of row +the seams have to shift. A three-channel case was measured too and moved inside the timer noise, so +it is not reported as a win here. Further seam-search savings have to come from the dynamic program +itself, not from bookkeeping. + +## P28's recorded number is stale + +`perf/baseline.json` records P28 (Content-Aware Scale on 24 MP to 90 % width) as 954 192 ms from the +2026-10-05 baseline, which is why `perf/budgets.toml` carries `enforce = false` for that row. The +seam-search proxies and the per-seam energy update landed after that capture: + +| Tree | 6000 × 4000 → 5400, 4 ch | +|---|---:| +| `f5d2bcb` (the 2026-10-05 baseline commit) | 266 039 ms | +| `1104b6f` (main, plus this change) | 2 559 ms | + +`f5d2bcb` has no `PROXY_SHRINK_MIN_HEIGHT`: it carves at full height with a full energy recompute per +seam. The 954 s in the baseline was that code on a machine whose recorded load average was 226–290 +on 14 cores. The two example runs above are 104× apart (266 039 ms against 2 559 ms); that is the +seam-search core alone, and it is not the same measurement as the row's 954 s, which also includes a +document read and writeback the timer above excludes. + +The row itself is not flipped to `enforce = true` here: the budget is set for the macOS/aarch64 +machine class and only a run on that class can vouch for it. The carving core above is 2.56 s at +24 MP on a 4-core laptop CPU; the budgeted machine is faster, but the row also includes the document +read and writeback this measurement excludes. Flip it in the PR whose nightly run shows the row +under 3 s, and delete this paragraph with it. + +## Reproduce + +```sh +cargo build --release -p photocraft-algo --example bench_seam +./target/release/examples/bench_seam 6000 4000 5400 4 # width height new_width channels (1..=4) +``` + +Build the base and the changed tree with separate `CARGO_TARGET_DIR`s and run the same line in both; +the printed FNV hash of the output must match, and the millisecond figure is the row above. The +example builds deterministic gradient-plus-detail content, so two runs of the same binary agree. + +## Correctness and repository checks + +`cargo test --release -p photocraft-algo seam` passes, including `carver_matches_reference`, +`output_bits_are_pinned`, `tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels` +and the cancellation test. `cargo fmt --all --check` and +`cargo clippy -p photocraft-algo --all-targets -- -D warnings` are clean. No command, UI or format +contract changed; `Carver`'s `track` mode is still used by the enlargement path, which needs the +original columns it was written for. diff --git a/perf/budgets.toml b/perf/budgets.toml index 83983e4206..6b264a936d 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -359,7 +359,9 @@ issue = 211 bench = "perf_scenarios" row = "Content-Aware Scale 24 MP to 90 % width" budget_ms = 3000 -enforce = false # over budget in the 2026-10-05 baseline +enforce = false # 954 s in the 2026-10-05 baseline, which predates the seam-search proxies: the +# carving core is 2.6 s at 24 MP RGBA (docs/seam-carving-performance.md). Flip once a +# budgeted-class run shows the whole row under 3 s. [[scenario]] id = "P29" From 3d64c717b65c6731d810e27d52f94ebf8338c24a Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 17:45:52 +0300 Subject: [PATCH 11/40] refactor(cli): split photocraft-cli into modules, no behaviour change (#7071) lib.rs held the whole CLI in one 754-line file. Split it into args, output, document, actions, batch and automation modules so the argument parser can be replaced with clap in a separate, reviewable change. Function bodies, doc comments and tests move unchanged. The only edits are mod/use lines, pub(crate) visibility on items now shared across modules, and the imports the moved test modules need. The public API (run, run_os, parse_actions, load_actions, load_actions_file, Steps, USAGE) is re-exported from lib.rs. Co-authored-by: Claude Sonnet 5.5 --- apps/photocraft-cli/src/actions.rs | 76 +++ apps/photocraft-cli/src/args.rs | 220 +++++++ apps/photocraft-cli/src/automation.rs | 73 +++ apps/photocraft-cli/src/batch.rs | 222 +++++++ apps/photocraft-cli/src/document.rs | 256 ++++++++ apps/photocraft-cli/src/lib.rs | 869 +------------------------- apps/photocraft-cli/src/output.rs | 59 ++ 7 files changed, 916 insertions(+), 859 deletions(-) create mode 100644 apps/photocraft-cli/src/actions.rs create mode 100644 apps/photocraft-cli/src/args.rs create mode 100644 apps/photocraft-cli/src/automation.rs create mode 100644 apps/photocraft-cli/src/batch.rs create mode 100644 apps/photocraft-cli/src/document.rs create mode 100644 apps/photocraft-cli/src/output.rs diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs new file mode 100644 index 0000000000..0e6ab01ff6 --- /dev/null +++ b/apps/photocraft-cli/src/actions.rs @@ -0,0 +1,76 @@ +use std::path::Path; + +use photocraft_engine::actions_cmds::Action; +use serde_json::{Value, json}; + +/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs, +/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a +/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489). +pub fn parse_actions(text: &str) -> Result, String> { + let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; + steps_of(&v) +} + +fn steps_of(v: &Value) -> Result { + photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string()) +} + +/// Engine commands to run in order: `(id, params)`. +pub type Steps = Vec<(String, Value)>; + +/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the +/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays +/// the action `name` picks (optional when the set holds one) with the whole set loaded, as +/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). +pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), String> { + let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; + let list = v.get("actions").unwrap_or(&v); + let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); + if !is_set { + if name.is_some() { + return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into()); + } + return Ok((steps_of(&v)?, Vec::new())); + } + let set: Vec = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?; + let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let action = match (name, set.as_slice()) { + (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?, + (None, [only]) => only, + (None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action ({})", set.len(), names())), + }; + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, set)) +} + +/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by +/// its version word) is converted first; the played action's steps with no PhotoCraft +/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run). +pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec, Vec), String> { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + if !is_atn { + let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; + let (steps, set) = load_actions(&text, name)?; + return Ok((steps, set, Vec::new())); + } + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; + let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let action = match (name, imp.actions.as_slice()) { + (Some(n), _) => { + imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? + } + (None, [only]) => only, + (None, []) => return Err(format!("{path}: the set holds no actions")), + (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), + }; + let warnings = imp + .unsupported + .iter() + .filter(|u| u.action == action.name) + .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) + .chain(imp.warnings.iter().cloned()) + .collect(); + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, imp.actions, warnings)) +} diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs new file mode 100644 index 0000000000..b567a4dcd7 --- /dev/null +++ b/apps/photocraft-cli/src/args.rs @@ -0,0 +1,220 @@ +use std::io::Write; + +use crate::{ + R, + automation::{commands, mcp, serve}, + batch::{batch, droplet}, + document::{convert, info, run_cmds}, +}; + +pub const USAGE: &str = "\ +photocraft-cli: headless PhotoCraft + +USAGE: + photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] + Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). + An that is the file is refused, as the result would replace the original; + --in-place allows it. + TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); + --tiff-compression compresses it (none, the default; lzw; zip). + --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. + --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as + its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. + --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. + --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 + (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. + --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized + (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so + only Gray and CMYK shrink) or progressive. + photocraft-cli info [--compact] + Print the document as JSON (size, mode, depth, layer tree). + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] + Open a file, run engine commands in order, save the result. Each --params + applies to the preceding --cmd. Prints each command's JSON result. An --out that is the + is refused, as the result would replace the original; --in-place allows it. + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] + Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a + set: its action --action names plays, as below. JSON steps are [id, params] pairs, + {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them + (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions + panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed + when the set holds more than one) with the whole set loaded, so its steps can play other actions + of the set. An --out folder that is the --in folder is refused, as the results would replace + the originals; --in-place allows it. + photocraft-cli droplet … [--out ] + Run a droplet (File › Automate › Create Droplet) on images and folders. + photocraft-cli commands [--json] [--filter ] + List the engine command registry. + photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file ] + [--automation-read-root ] [--automation-write-root ] + Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `). + Headless: each root grants that direction of file access; an omitted root denies it. + Prefer absolute root paths in MCP client configurations. + Bridge: set roots on the desktop app; CLI root flags are rejected. Explicit token flags + require --bridge; credentials can also use PHOTOCRAFT_CONTROL_TOKEN or PHOTOCRAFT_CONTROL_TOKEN_FILE. + photocraft-cli serve [--port ] [--control-token <64-hex> | --control-token-file ] + [--automation-read-root ] [--automation-write-root ] + Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio, + or on 127.0.0.1:. Methods: engine.execute, jobs.list/cancel, engine.commands, + doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods + (docs/control-protocol.md#headless-server). + Explicit token flags require --port. Stdio needs no control token. + + photocraft-cli --help (or -h) prints this text. A flag the subcommand doesn't take is + an error. mcp and serve take no positional arguments; their options cannot be repeated or empty. +"; + +pub(crate) struct Args { + pub(crate) positional: Vec, + pub(crate) flags: Vec<(String, Option)>, +} + +/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag +/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423). +pub(crate) struct Subcommand { + pub(crate) name: &'static str, + pub(crate) values: &'static [&'static str], + pub(crate) bare: &'static [&'static str], + pub(crate) run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R, +} + +pub(crate) const SUBCOMMANDS: &[Subcommand] = &[ + Subcommand { + name: "convert", + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], + run: convert, + }, + Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, + Subcommand { + name: "run", + values: &[ + "--new", + "--cmd", + "--params", + "--out", + "--format", + "--quality", + "--tga-bits", + "--dpx-bits", + "--dds-compression", + "--tiff-compression", + "--jpeg-format", + ], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], + run: run_cmds, + }, + Subcommand { + name: "batch", + values: &[ + "--actions", + "--action", + "--in", + "--out", + "--format", + "--quality", + "--tga-bits", + "--dpx-bits", + "--dds-compression", + "--tiff-compression", + "--jpeg-format", + ], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], + run: batch, + }, + Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, + Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) }, + Subcommand { + name: "mcp", + values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], + bare: &[], + run: |a, _, _| mcp(a), + }, + Subcommand { + name: "serve", + values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], + bare: &[], + run: |a, _, err| serve(a, err), + }, +]; + +/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`). +pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, String> { + let mut a = Args { positional: Vec::new(), flags: Vec::new() }; + let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name); + let mut i = 0; + while let Some(s) = args.get(i) { + if s == "--help" || s == "-h" { + return Ok(None); + } + if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { + if sub.bare.contains(&k) { + return Err(format!("{k} takes no value")); + } + if !sub.values.contains(&k) { + return Err(unknown(k)); + } + a.flags.push((k.to_owned(), Some(v.to_owned()))); + } else if sub.values.contains(&s.as_str()) { + // A flag where the value should be is a missing value, not the value; help still wins. + let v = args.get(i + 1); + if v.is_some_and(|v| v == "--help" || v == "-h") { + return Ok(None); + } + let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| format!("{s} needs a value"))?; + a.flags.push((s.clone(), Some(v.clone()))); + i += 1; + } else if sub.bare.contains(&s.as_str()) { + a.flags.push((s.clone(), None)); + } else if s.starts_with("--") { + return Err(unknown(s)); + } else { + a.positional.push(s.clone()); + } + i += 1; + } + validate_server_args(sub.name, &a)?; + Ok(Some(a)) +} + +/// Refuse launch options that would otherwise be silently ignored. In particular, bridge +/// roots belong to the desktop process: accepting them here promises authority we cannot set. +fn validate_server_args(sub: &str, a: &Args) -> R { + if !matches!(sub, "mcp" | "serve") { + return Ok(()); + } + if !a.positional.is_empty() { + return Err(format!("{sub} does not take positional arguments")); + } + let mut seen = std::collections::HashSet::new(); + for (flag, value) in &a.flags { + if !seen.insert(flag) { + return Err(format!("{flag} may only be specified once")); + } + if value.as_deref().is_none_or(str::is_empty) { + return Err(format!("{flag} needs a non-empty value")); + } + } + if a.has("--control-token") && a.has("--control-token-file") { + return Err("use either --control-token or --control-token-file, not both".into()); + } + let transport = if sub == "mcp" { "--bridge" } else { "--port" }; + for flag in ["--control-token", "--control-token-file"] { + if a.has(flag) && !a.has(transport) { + return Err(format!("{flag} requires {transport}")); + } + } + if sub == "mcp" && a.has("--bridge") && (a.has("--automation-read-root") || a.has("--automation-write-root")) { + return Err("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode".into()); + } + Ok(()) +} + +impl Args { + pub(crate) fn get(&self, k: &str) -> Option<&str> { + self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref()) + } + pub(crate) fn has(&self, k: &str) -> bool { + self.flags.iter().any(|(f, _)| f == k) + } +} diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs new file mode 100644 index 0000000000..064592b6f2 --- /dev/null +++ b/apps/photocraft-cli/src/automation.rs @@ -0,0 +1,73 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, security}; + +use crate::{R, args::Args, output::print_json}; + +pub(crate) fn automation_workspace(args: &Args) -> Result { + AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new)) + .map_err(|error| error.to_string()) +} + +pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> R { + let h = Headless::new(); + let list = h.command_list(); + let needle = a.get("--filter").map(str::to_lowercase); + let items: Vec<&Value> = list + .as_array() + .map(|v| v.iter().collect::>()) + .unwrap_or_default() + .into_iter() + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) + .collect(); + if a.has("--json") { + 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::>().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(()) +} + +pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> R { + use std::sync::{Arc, Mutex}; + let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?))); + match a.get("--port") { + Some(port) => { + let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?; + let addr = format!("127.0.0.1:{port}"); + let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); + let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; + if let Some(path) = &token_file { + let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); + } else if supplied.is_none() { + let _ = writeln!(err, "photocraft-cli control token: {token}"); + } + photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| { + let _ = writeln!(err, "photocraft-cli serving on {local}"); + }) + .map_err(|e| e.to_string()) + } + None => { + let stdin = std::io::stdin(); + photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) + } + } +} + +pub(crate) fn mcp(a: &Args) -> R { + let server = match a.get("--bridge") { + Some(addr) => { + let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); + let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; + PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())? + } + None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?), + }; + 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()) +} diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs new file mode 100644 index 0000000000..e02351d13b --- /dev/null +++ b/apps/photocraft-cli/src/batch.rs @@ -0,0 +1,222 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use photocraft_automation::Headless; +use photocraft_engine::actions_cmds::Action; +use serde_json::json; + +use crate::{ + R, + actions::load_actions_file, + args::Args, + output::{export_opts, warn_all}, +}; + +/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a +/// trailing separator, through a symbolic link). +fn same_dir(a: &Path, b: &Path) -> bool { + matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b) +} + +pub(crate) fn is_input(p: &Path) -> bool { + let known = |e: &str| { + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") + || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) + }; + p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) +} + +/// Drops the Save and view steps the desktop records from `action` (Batch saves each result +/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). +fn strip_shell_steps(action: &mut Action) { + use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; + let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); + for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { + if shell_save_command(&id) || shell_view_command(&id) { + continue; + } + if action.disabled.contains(&i) { + disabled.insert(steps.len()); + } + if action.dialogs.contains(&i) { + dialogs.insert(steps.len()); + } + steps.push((id, p)); + } + (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); +} + +pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { + let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; + let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); + let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); + let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + set.iter_mut().for_each(strip_shell_steps); + for w in &warnings { + let _ = writeln!(err, "warning: {w}"); + } + let opts = export_opts(a)?; + // Results are saved as `/.`, so an `--out` that is the `--in` folder would + // replace the originals (#492). + if !a.has("--in-place") && same_dir(&in_dir, &out_dir) { + return Err(format!( + "--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", + out_dir.display() + )); + } + std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; + let mut inputs: Vec = + 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); + let mut written = photocraft_engine::file_cmds::OutputClaims::default(); + // `--format .jpg` names outputs `.jpg`, as `--format jpg` does (#490). + let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f)); + for input in &inputs { + let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into()); + let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let target = out_dir.join(format!("{stem}.{ext}")); + let target_text = target.to_string_lossy(); + let r = (|| -> Result, String> { + written.check(&target_text)?; + let mut h = Headless::trusted_local(); + // Steps that play another action find it in the set; the session's playback stack + // stops an action that calls itself. + h.session.actions.list = set.clone(); + h.open(input).map_err(|e| e.to_string())?; + for (id, p) in &actions { + if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { + continue; + } + let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) { + return Err(format!("`{id}`: {e}")); + } + } + 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 { + Ok(ws) => { + ok += 1; + written.record(&target_text, &input.to_string_lossy()); + let _ = writeln!(out, "ok {} -> {}", input.display(), target.display()); + warn_all(err, &ws); + } + Err(e) => { + failed += 1; + let _ = writeln!(err, "FAIL {}: {e}", input.display()); + } + } + } + let _ = writeln!(out, "{ok} succeeded, {failed} failed"); + if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) } +} + +pub(crate) fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { + let (file, inputs) = a.positional.split_first().ok_or("droplet needs and inputs")?; + if inputs.is_empty() { + return Err("droplet needs at least one input file or folder".into()); + } + let mut p = json!({"droplet": file, "input": inputs}); + if let Some(o) = a.get("--out") { + 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())?; + 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())) } +} + +#[cfg(test)] +mod batch_action_set_tests { + use super::*; + use crate::run; + use serde_json::Value; + + fn invoke(args: &[&str]) -> (i32, String, String) { + let (mut output, mut errors) = (Vec::new(), Vec::new()); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let code = run(&args, &mut output, &mut errors); + (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) + } + + /// A batched action that plays another action of its set finds it in every file's session; a + /// failure inside the called action fails the file, and a self-call stops instead of looping. + #[test] + fn batch_plays_an_action_that_calls_another_action_of_its_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + for name in ["a", "b"] { + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); + assert_eq!(code, 0, "{stderr}"); + } + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]}, + {"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]}, + {"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]}, + {"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]}, + {"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + + let (code, stdout, stderr) = batch("Outer"); + assert_eq!(code, 0, "{stdout}{stderr}"); + for name in ["a", "b"] { + let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action"); + } + for action in ["Calls bad", "Loop"] { + let (code, stdout, stderr) = batch(action); + assert_ne!(code, 0, "{stdout}"); + assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}"); + assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// Save and view steps recorded inside a batched action set are skipped, also in a called + /// action, and unchecked steps stay unchecked once they are gone (#6860). + #[test] + fn batch_skips_save_and_view_steps_inside_an_action_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, + {"name": "Rotate", "steps": [ + ["file.save", {}], + ["image.imageRotation.90cw", {}], + ["view.fitOnScreen", {}], + ["image.imageRotation.90cw", {}], + ["file.saveAs", {}], + ], "disabled": [3], "dialogs": [1]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + for action in ["Outer", "Rotate"] { + let (code, stdout, stderr) = + invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + assert_eq!(code, 0, "{stdout}{stderr}"); + let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs new file mode 100644 index 0000000000..81b6df406c --- /dev/null +++ b/apps/photocraft-cli/src/document.rs @@ -0,0 +1,256 @@ +use std::io::Write; +use std::path::Path; + +use photocraft_automation::{Headless, files}; +use serde_json::{Value, json}; + +use crate::{ + R, + args::Args, + output::{export_opts, missing_font_warnings, print_json, warn_all}, +}; + +/// An output that is the input file itself, however it is spelt (relative, with `./`, through a +/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it +/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` +/// folder (#7437). The paths are compared when the command starts and the save opens `output` +/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards +/// against mistakes, it is not a security boundary. +fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { + if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { + return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); + } + Ok(()) +} + +pub(crate) fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { + let [input, output] = a.positional.as_slice() else { + return Err("convert needs ".into()); + }; + let opts = export_opts(a)?; + refuse_overwriting_input(a, input, output)?; + let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. + photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); + warn_all(err, &o.warnings); + warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); + let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; + warn_all(err, &ws); + Ok(()) +} + +pub(crate) fn info(a: &Args, out: &mut dyn Write) -> R { + let [file] = a.positional.as_slice() else { + return Err("info needs ".into()); + }; + let mut h = Headless::trusted_local(); + let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?; + let mut doc = h.inspect(None).map_err(|e| e.to_string())?; + let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); + if let Some(st) = h.session.active() { + warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String)); + } + doc["warnings"] = Value::Array(warnings); + doc["file"] = json!(file); + // Drop per-session noise. + if let Value::Object(m) = &mut doc { + for k in ["history", "canUndo", "canRedo", "revision"] { + m.remove(k); + } + } + print_json(out, &doc, a.has("--compact")) +} + +/// `(id, params)` pairs from repeated `--cmd`/`--params` flags. +fn command_list(a: &Args) -> Result, String> { + let mut cmds: Vec<(String, Value)> = Vec::new(); + for (k, v) in &a.flags { + match (k.as_str(), v) { + ("--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))?; + } + _ => {} + } + } + Ok(cmds) +} + +pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { + let opts = export_opts(a)?; + let mut h = Headless::trusted_local(); + // Opening, running commands and exporting can all notice the same missing font: + // one `run` invocation reports each warning line at most once. + let mut reported_fonts = std::collections::HashSet::::new(); + match (a.positional.as_slice(), a.get("--new")) { + ([file], None) => { + // Refused before the file is read or any command runs. + if let Some(out) = a.get("--out") { + refuse_overwriting_input(a, file, out)?; + } + let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; + let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); + reported_fonts.extend(ws.iter().cloned()); + warn_all(err, &ws); + reported_fonts.extend(ws); + } + ([], Some(new)) => { + let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; + h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?; + } + _ => return Err("run needs exactly one of or --new ".into()), + } + let cmds = command_list(a)?; + if cmds.is_empty() && a.get("--out").is_none() { + 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}"))?; + if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { + return Err(format!("`{id}`: {e}")); + } + let new_warnings: Vec = h + .session + .active() + .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) + .unwrap_or_default() + .into_iter() + .filter(|w| reported_fonts.insert(w.clone())) + .collect(); + warn_all(err, &new_warnings); + let mut result = json!({"command": id, "result": r}); + if !new_warnings.is_empty() { + result["warnings"] = json!(new_warnings); + } + print_json(out, &result, true)?; + } + // A `run` with only `--out` still reports missing fonts before exporting. + let remaining: Vec = h + .session + .active() + .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) + .unwrap_or_default() + .into_iter() + .filter(|w| reported_fonts.insert(w.clone())) + .collect(); + warn_all(err, &remaining); + if let Some(o) = a.get("--out") { + let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; + let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &ws); + } + Ok(()) +} + +#[cfg(test)] +mod missing_font_warning_tests { + use super::*; + use crate::{batch::is_input, run}; + + fn invoke(args: &[&str]) -> (i32, String, String) { + let mut output = Vec::new(); + let mut errors = Vec::new(); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let code = run(&args, &mut output, &mut errors); + (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) + } + + #[test] + fn missing_typeface_is_reported_by_run_info_and_convert() { + let family = "DefinitelyMissingPhotoCraftTypeface1251"; + let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let native = folder.join("missing.pcraft"); + let png = folder.join("missing.png"); + let native_str = native.to_string_lossy().to_string(); + let png_str = png.to_string_lossy().to_string(); + let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string(); + + let (code, stdout, stderr) = invoke(&[ + "run", + "--new", + r#"{"width":256,"height":96,"background":"white"}"#, + "--cmd", + "type.create", + "--params", + ¶ms, + "--cmd", + "type.resolveMissingFonts", + "--out", + &native_str, + ]); + assert_eq!(code, 0, "{stderr}"); + assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning"); + let commands: Vec = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect(); + assert_eq!(commands.len(), 2, "{stdout}"); + assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); + assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported"); + + let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); + assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr"); + + let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}"); + assert!(png.is_file()); + + // Even with no commands, run --out checks the opened document before export. + let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(stdout.is_empty()); + assert_eq!(stderr.matches(family).count(), 1); + + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn convert_reads_and_writes_openraster() { + let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = |name: &str| folder.join(name).to_string_lossy().to_string(); + let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png")); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); + assert_eq!(code, 0, "{stderr}"); + let (code, _, stderr) = invoke(&["convert", &native, &ora]); + assert_eq!(code, 0, "{stderr}"); + let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}"); + let (code, _, stderr) = invoke(&["convert", &ora, &png]); + assert_eq!(code, 0, "{stderr}"); + assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn no_type_layers_do_not_produce_missing_font_warnings() { + let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let native = folder.join("blank.pcraft"); + let png = folder.join("blank.png"); + let native_str = native.to_string_lossy().to_string(); + let png_str = png.to_string_lossy().to_string(); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stderr.contains("font '"), "{stderr}"); + let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '"))); + let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stderr.contains("font '"), "{stderr}"); + let _ = std::fs::remove_dir_all(&folder); + } +} diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index d5fc149607..c04838086e 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -4,226 +4,19 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::io::Write; -use std::path::{Path, PathBuf}; -use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security}; -use photocraft_engine::actions_cmds::Action; -use photocraft_io::ExportOptions; -use serde_json::{Value, json}; +mod actions; +mod args; +mod automation; +mod batch; +mod document; +mod output; -pub const USAGE: &str = "\ -photocraft-cli: headless PhotoCraft +pub use actions::{Steps, load_actions, load_actions_file, parse_actions}; +pub use args::USAGE; +use args::{SUBCOMMANDS, parse}; -USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - An that is the file is refused, as the result would replace the original; - --in-place allows it. - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); - --tiff-compression compresses it (none, the default; lzw; zip). - --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. - --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as - its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. - --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. - --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 - (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. - --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized - (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so - only Gray and CMYK shrink) or progressive. - photocraft-cli info [--compact] - Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Open a file, run engine commands in order, save the result. Each --params - applies to the preceding --cmd. Prints each command's JSON result. An --out that is the - is refused, as the result would replace the original; --in-place allows it. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a - set: its action --action names plays, as below. JSON steps are [id, params] pairs, - {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them - (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions - panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed - when the set holds more than one) with the whole set loaded, so its steps can play other actions - of the set. An --out folder that is the --in folder is refused, as the results would replace - the originals; --in-place allows it. - photocraft-cli droplet … [--out ] - Run a droplet (File › Automate › Create Droplet) on images and folders. - photocraft-cli commands [--json] [--filter ] - List the engine command registry. - photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `). - Headless: each root grants that direction of file access; an omitted root denies it. - Prefer absolute root paths in MCP client configurations. - Bridge: set roots on the desktop app; CLI root flags are rejected. Explicit token flags - require --bridge; credentials can also use PHOTOCRAFT_CONTROL_TOKEN or PHOTOCRAFT_CONTROL_TOKEN_FILE. - photocraft-cli serve [--port ] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio, - or on 127.0.0.1:. Methods: engine.execute, jobs.list/cancel, engine.commands, - doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods - (docs/control-protocol.md#headless-server). - Explicit token flags require --port. Stdio needs no control token. - - photocraft-cli --help (or -h) prints this text. A flag the subcommand doesn't take is - an error. mcp and serve take no positional arguments; their options cannot be repeated or empty. -"; - -struct Args { - positional: Vec, - flags: Vec<(String, Option)>, -} - -/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag -/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423). -struct Subcommand { - name: &'static str, - values: &'static [&'static str], - bare: &'static [&'static str], - run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R, -} - -const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { - name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: convert, - }, - Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, - Subcommand { - name: "run", - values: &[ - "--new", - "--cmd", - "--params", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: run_cmds, - }, - Subcommand { - name: "batch", - values: &[ - "--actions", - "--action", - "--in", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: batch, - }, - Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, - Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) }, - Subcommand { - name: "mcp", - values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, _| mcp(a), - }, - Subcommand { - name: "serve", - values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, err| serve(a, err), - }, -]; - -/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`). -fn parse(sub: &Subcommand, args: &[String]) -> Result, String> { - let mut a = Args { positional: Vec::new(), flags: Vec::new() }; - let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name); - let mut i = 0; - while let Some(s) = args.get(i) { - if s == "--help" || s == "-h" { - return Ok(None); - } - if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { - if sub.bare.contains(&k) { - return Err(format!("{k} takes no value")); - } - if !sub.values.contains(&k) { - return Err(unknown(k)); - } - a.flags.push((k.to_owned(), Some(v.to_owned()))); - } else if sub.values.contains(&s.as_str()) { - // A flag where the value should be is a missing value, not the value; help still wins. - let v = args.get(i + 1); - if v.is_some_and(|v| v == "--help" || v == "-h") { - return Ok(None); - } - let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| format!("{s} needs a value"))?; - a.flags.push((s.clone(), Some(v.clone()))); - i += 1; - } else if sub.bare.contains(&s.as_str()) { - a.flags.push((s.clone(), None)); - } else if s.starts_with("--") { - return Err(unknown(s)); - } else { - a.positional.push(s.clone()); - } - i += 1; - } - validate_server_args(sub.name, &a)?; - Ok(Some(a)) -} - -/// Refuse launch options that would otherwise be silently ignored. In particular, bridge -/// roots belong to the desktop process: accepting them here promises authority we cannot set. -fn validate_server_args(sub: &str, a: &Args) -> R { - if !matches!(sub, "mcp" | "serve") { - return Ok(()); - } - if !a.positional.is_empty() { - return Err(format!("{sub} does not take positional arguments")); - } - let mut seen = std::collections::HashSet::new(); - for (flag, value) in &a.flags { - if !seen.insert(flag) { - return Err(format!("{flag} may only be specified once")); - } - if value.as_deref().is_none_or(str::is_empty) { - return Err(format!("{flag} needs a non-empty value")); - } - } - if a.has("--control-token") && a.has("--control-token-file") { - return Err("use either --control-token or --control-token-file, not both".into()); - } - let transport = if sub == "mcp" { "--bridge" } else { "--port" }; - for flag in ["--control-token", "--control-token-file"] { - if a.has(flag) && !a.has(transport) { - return Err(format!("{flag} requires {transport}")); - } - } - if sub == "mcp" && a.has("--bridge") && (a.has("--automation-read-root") || a.has("--automation-write-root")) { - return Err("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode".into()); - } - Ok(()) -} - -impl Args { - fn get(&self, k: &str) -> Option<&str> { - 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) - } -} - -type R = Result<(), String>; +pub(crate) type R = Result<(), String>; /// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error /// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts @@ -283,645 +76,3 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { } } } - -fn export_opts(a: &Args) -> Result { - // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() }; - if let Some(q) = a.get("--quality") { - let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?; - o.encode.jpeg_quality = q; - // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. - o.encode.webp_quality = q; - o.encode.webp_lossless = false; - } - if let Some(b) = a.get("--tga-bits") { - o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); - } - if let Some(b) = a.get("--dpx-bits") { - o.encode.dpx_bits = - Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); - } - if let Some(c) = a.get("--tiff-compression") { - o.encode.tiff_compression = match c { - "none" => photocraft_codecs::TiffCompression::None, - "lzw" => photocraft_codecs::TiffCompression::Lzw, - "zip" => photocraft_codecs::TiffCompression::Deflate, - _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), - }; - } - if let Some(f) = a.get("--jpeg-format") { - o.encode.jpeg_format = - photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; - } - if let Some(c) = a.get("--dds-compression") { - o.encode.dds.compression = - photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; - } - o.encode.dds.mipmaps = a.has("--dds-mipmaps"); - Ok(o) -} - -fn warn_all(err: &mut dyn Write, ws: &[String]) { - for w in ws { - let _ = writeln!(err, "warning: {w}"); - } -} - -/// The engine knows which type families aren't installed. Warn before an export -/// uses a fallback face rather than silently changing the appearance of the text. -fn missing_font_warnings(fonts: Vec) -> Vec { - fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect() -} - -fn automation_workspace(args: &Args) -> Result { - AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new)) - .map_err(|error| error.to_string()) -} - -/// An output that is the input file itself, however it is spelt (relative, with `./`, through a -/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it -/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` -/// folder (#7437). The paths are compared when the command starts and the save opens `output` -/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards -/// against mistakes, it is not a security boundary. -fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { - if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { - return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); - } - Ok(()) -} - -fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { - let [input, output] = a.positional.as_slice() else { - return Err("convert needs ".into()); - }; - let opts = export_opts(a)?; - refuse_overwriting_input(a, input, output)?; - let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; - // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. - photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); - warn_all(err, &o.warnings); - warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, 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())?; - writeln!(out, "{s}").map_err(|e| e.to_string()) -} - -fn info(a: &Args, out: &mut dyn Write) -> R { - let [file] = a.positional.as_slice() else { - return Err("info needs ".into()); - }; - let mut h = Headless::trusted_local(); - let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?; - let mut doc = h.inspect(None).map_err(|e| e.to_string())?; - let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); - if let Some(st) = h.session.active() { - warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String)); - } - doc["warnings"] = Value::Array(warnings); - doc["file"] = json!(file); - // Drop per-session noise. - if let Value::Object(m) = &mut doc { - for k in ["history", "canUndo", "canRedo", "revision"] { - m.remove(k); - } - } - print_json(out, &doc, a.has("--compact")) -} - -/// `(id, params)` pairs from repeated `--cmd`/`--params` flags. -fn command_list(a: &Args) -> Result, String> { - let mut cmds: Vec<(String, Value)> = Vec::new(); - for (k, v) in &a.flags { - match (k.as_str(), v) { - ("--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))?; - } - _ => {} - } - } - Ok(cmds) -} - -fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let opts = export_opts(a)?; - let mut h = Headless::trusted_local(); - // Opening, running commands and exporting can all notice the same missing font: - // one `run` invocation reports each warning line at most once. - let mut reported_fonts = std::collections::HashSet::::new(); - match (a.positional.as_slice(), a.get("--new")) { - ([file], None) => { - // Refused before the file is read or any command runs. - if let Some(out) = a.get("--out") { - refuse_overwriting_input(a, file, out)?; - } - let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); - reported_fonts.extend(ws.iter().cloned()); - warn_all(err, &ws); - reported_fonts.extend(ws); - } - ([], Some(new)) => { - let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; - h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?; - } - _ => return Err("run needs exactly one of or --new ".into()), - } - let cmds = command_list(a)?; - if cmds.is_empty() && a.get("--out").is_none() { - 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}"))?; - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { - return Err(format!("`{id}`: {e}")); - } - let new_warnings: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); - warn_all(err, &new_warnings); - let mut result = json!({"command": id, "result": r}); - if !new_warnings.is_empty() { - result["warnings"] = json!(new_warnings); - } - print_json(out, &result, true)?; - } - // A `run` with only `--out` still reports missing fonts before exporting. - let remaining: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); - warn_all(err, &remaining); - if let Some(o) = a.get("--out") { - let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); - let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); - warn_all(err, &ws); - } - Ok(()) -} - -/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs, -/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a -/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489). -pub fn parse_actions(text: &str) -> Result, String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; - steps_of(&v) -} - -fn steps_of(v: &Value) -> Result { - photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string()) -} - -/// Engine commands to run in order: `(id, params)`. -pub type Steps = Vec<(String, Value)>; - -/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the -/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays -/// the action `name` picks (optional when the set holds one) with the whole set loaded, as -/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). -pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; - let list = v.get("actions").unwrap_or(&v); - let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); - if !is_set { - if name.is_some() { - return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into()); - } - return Ok((steps_of(&v)?, Vec::new())); - } - let set: Vec = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?; - let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); - let action = match (name, set.as_slice()) { - (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?, - (None, [only]) => only, - (None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action ({})", set.len(), names())), - }; - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; - Ok((play, set)) -} - -/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by -/// its version word) is converted first; the played action's steps with no PhotoCraft -/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run). -pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec, Vec), String> { - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); - if !is_atn { - let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; - let (steps, set) = load_actions(&text, name)?; - return Ok((steps, set, Vec::new())); - } - let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; - let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); - let action = match (name, imp.actions.as_slice()) { - (Some(n), _) => { - imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? - } - (None, [only]) => only, - (None, []) => return Err(format!("{path}: the set holds no actions")), - (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), - }; - let warnings = imp - .unsupported - .iter() - .filter(|u| u.action == action.name) - .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) - .chain(imp.warnings.iter().cloned()) - .collect(); - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; - Ok((play, imp.actions, warnings)) -} - -/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a -/// trailing separator, through a symbolic link). -fn same_dir(a: &Path, b: &Path) -> bool { - matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b) -} - -fn is_input(p: &Path) -> bool { - let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") - || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) - }; - p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) -} - -/// Drops the Save and view steps the desktop records from `action` (Batch saves each result -/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). -fn strip_shell_steps(action: &mut Action) { - use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; - let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); - for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { - if shell_save_command(&id) || shell_view_command(&id) { - continue; - } - if action.disabled.contains(&i) { - disabled.insert(steps.len()); - } - if action.dialogs.contains(&i) { - dialogs.insert(steps.len()); - } - steps.push((id, p)); - } - (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); -} - -fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; - let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); - let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; - set.iter_mut().for_each(strip_shell_steps); - for w in &warnings { - let _ = writeln!(err, "warning: {w}"); - } - let opts = export_opts(a)?; - // Results are saved as `/.`, so an `--out` that is the `--in` folder would - // replace the originals (#492). - if !a.has("--in-place") && same_dir(&in_dir, &out_dir) { - return Err(format!( - "--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", - out_dir.display() - )); - } - std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let mut inputs: Vec = - 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); - let mut written = photocraft_engine::file_cmds::OutputClaims::default(); - // `--format .jpg` names outputs `.jpg`, as `--format jpg` does (#490). - let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f)); - for input in &inputs { - let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into()); - let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); - let target = out_dir.join(format!("{stem}.{ext}")); - let target_text = target.to_string_lossy(); - let r = (|| -> Result, String> { - written.check(&target_text)?; - let mut h = Headless::trusted_local(); - // Steps that play another action find it in the set; the session's playback stack - // stops an action that calls itself. - h.session.actions.list = set.clone(); - h.open(input).map_err(|e| e.to_string())?; - for (id, p) in &actions { - if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { - continue; - } - let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; - // A failed step inside a called action makes this file an error, not a saved result. - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) { - return Err(format!("`{id}`: {e}")); - } - } - 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 { - Ok(ws) => { - ok += 1; - written.record(&target_text, &input.to_string_lossy()); - let _ = writeln!(out, "ok {} -> {}", input.display(), target.display()); - warn_all(err, &ws); - } - Err(e) => { - failed += 1; - let _ = writeln!(err, "FAIL {}: {e}", input.display()); - } - } - } - let _ = writeln!(out, "{ok} succeeded, {failed} failed"); - if failed > 0 { Err(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 and inputs")?; - if inputs.is_empty() { - return Err("droplet needs at least one input file or folder".into()); - } - let mut p = json!({"droplet": file, "input": inputs}); - if let Some(o) = a.get("--out") { - 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())?; - 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())) } -} - -fn commands(a: &Args, out: &mut dyn Write) -> R { - let h = Headless::new(); - let list = h.command_list(); - let needle = a.get("--filter").map(str::to_lowercase); - let items: Vec<&Value> = list - .as_array() - .map(|v| v.iter().collect::>()) - .unwrap_or_default() - .into_iter() - .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) - .collect(); - if a.has("--json") { - 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::>().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(()) -} - -fn serve(a: &Args, err: &mut dyn Write) -> R { - use std::sync::{Arc, Mutex}; - let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?))); - match a.get("--port") { - Some(port) => { - let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?; - let addr = format!("127.0.0.1:{port}"); - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; - if let Some(path) = &token_file { - let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); - } else if supplied.is_none() { - let _ = writeln!(err, "photocraft-cli control token: {token}"); - } - photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| { - let _ = writeln!(err, "photocraft-cli serving on {local}"); - }) - .map_err(|e| e.to_string()) - } - None => { - let stdin = std::io::stdin(); - photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) - } - } -} - -fn mcp(a: &Args) -> R { - let server = match a.get("--bridge") { - Some(addr) => { - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; - PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())? - } - None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?), - }; - 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()) -} - -#[cfg(test)] -mod missing_font_warning_tests { - use super::*; - - fn invoke(args: &[&str]) -> (i32, String, String) { - let mut output = Vec::new(); - let mut errors = Vec::new(); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) - } - - #[test] - fn missing_typeface_is_reported_by_run_info_and_convert() { - let family = "DefinitelyMissingPhotoCraftTypeface1251"; - let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let native = folder.join("missing.pcraft"); - let png = folder.join("missing.png"); - let native_str = native.to_string_lossy().to_string(); - let png_str = png.to_string_lossy().to_string(); - let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string(); - - let (code, stdout, stderr) = invoke(&[ - "run", - "--new", - r#"{"width":256,"height":96,"background":"white"}"#, - "--cmd", - "type.create", - "--params", - ¶ms, - "--cmd", - "type.resolveMissingFonts", - "--out", - &native_str, - ]); - assert_eq!(code, 0, "{stderr}"); - assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning"); - let commands: Vec = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect(); - assert_eq!(commands.len(), 2, "{stdout}"); - assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); - assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported"); - - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); - assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr"); - - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}"); - assert!(png.is_file()); - - // Even with no commands, run --out checks the opened document before export. - let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(stdout.is_empty()); - assert_eq!(stderr.matches(family).count(), 1); - - let _ = std::fs::remove_dir_all(&folder); - } - - #[test] - fn convert_reads_and_writes_openraster() { - let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let path = |name: &str| folder.join(name).to_string_lossy().to_string(); - let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png")); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); - assert_eq!(code, 0, "{stderr}"); - let (code, _, stderr) = invoke(&["convert", &native, &ora]); - assert_eq!(code, 0, "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}"); - let (code, _, stderr) = invoke(&["convert", &ora, &png]); - assert_eq!(code, 0, "{stderr}"); - assert!(is_input(std::path::Path::new(&ora))); - // Krita documents are batch inputs too (import-only). - for name in ["sketch.kra", "old.KRZ"] { - std::fs::write(folder.join(name), b"").unwrap(); - assert!(is_input(&folder.join(name)), "{name}"); - } - let _ = std::fs::remove_dir_all(&folder); - } - - #[test] - fn no_type_layers_do_not_produce_missing_font_warnings() { - let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let native = folder.join("blank.pcraft"); - let png = folder.join("blank.png"); - let native_str = native.to_string_lossy().to_string(); - let png_str = png.to_string_lossy().to_string(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(!stderr.contains("font '"), "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '"))); - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(!stderr.contains("font '"), "{stderr}"); - let _ = std::fs::remove_dir_all(&folder); - } -} - -#[cfg(test)] -mod batch_action_set_tests { - use super::*; - - fn invoke(args: &[&str]) -> (i32, String, String) { - let (mut output, mut errors) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) - } - - /// A batched action that plays another action of its set finds it in every file's session; a - /// failure inside the called action fails the file, and a self-call stops instead of looping. - #[test] - fn batch_plays_an_action_that_calls_another_action_of_its_set() { - let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let path = |name: &str| dir.join(name).to_string_lossy().to_string(); - std::fs::create_dir_all(dir.join("in")).unwrap(); - for name in ["a", "b"] { - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); - assert_eq!(code, 0, "{stderr}"); - } - let set = json!({"version": 1, "actions": [ - {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]}, - {"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]}, - {"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]}, - {"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]}, - {"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]}, - ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); - let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); - - let (code, stdout, stderr) = batch("Outer"); - assert_eq!(code, 0, "{stdout}{stderr}"); - for name in ["a", "b"] { - let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action"); - } - for action in ["Calls bad", "Loop"] { - let (code, stdout, stderr) = batch(action); - assert_ne!(code, 0, "{stdout}"); - assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}"); - assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// Save and view steps recorded inside a batched action set are skipped, also in a called - /// action, and unchecked steps stay unchecked once they are gone (#6860). - #[test] - fn batch_skips_save_and_view_steps_inside_an_action_set() { - let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let path = |name: &str| dir.join(name).to_string_lossy().to_string(); - std::fs::create_dir_all(dir.join("in")).unwrap(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); - assert_eq!(code, 0, "{stderr}"); - let set = json!({"version": 1, "actions": [ - {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, - {"name": "Rotate", "steps": [ - ["file.save", {}], - ["image.imageRotation.90cw", {}], - ["view.fitOnScreen", {}], - ["image.imageRotation.90cw", {}], - ["file.saveAs", {}], - ], "disabled": [3], "dialogs": [1]}, - ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); - for action in ["Outer", "Rotate"] { - let (code, stdout, stderr) = - invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); - assert_eq!(code, 0, "{stdout}{stderr}"); - let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); - } - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs new file mode 100644 index 0000000000..973d75d9cb --- /dev/null +++ b/apps/photocraft-cli/src/output.rs @@ -0,0 +1,59 @@ +use photocraft_io::ExportOptions; +use serde_json::Value; +use std::io::Write; + +use crate::{R, args::Args}; + +pub(crate) fn export_opts(a: &Args) -> Result { + // TIFF output is flat unless asked: scripted conversions keep predictable sizes. + let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() }; + if let Some(q) = a.get("--quality") { + let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?; + o.encode.jpeg_quality = q; + // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. + o.encode.webp_quality = q; + o.encode.webp_lossless = false; + } + if let Some(b) = a.get("--tga-bits") { + o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); + } + if let Some(b) = a.get("--dpx-bits") { + o.encode.dpx_bits = + Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + } + if let Some(c) = a.get("--tiff-compression") { + o.encode.tiff_compression = match c { + "none" => photocraft_codecs::TiffCompression::None, + "lzw" => photocraft_codecs::TiffCompression::Lzw, + "zip" => photocraft_codecs::TiffCompression::Deflate, + _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + }; + } + if let Some(f) = a.get("--jpeg-format") { + o.encode.jpeg_format = + photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; + } + if let Some(c) = a.get("--dds-compression") { + o.encode.dds.compression = + photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; + } + o.encode.dds.mipmaps = a.has("--dds-mipmaps"); + Ok(o) +} + +pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) { + for w in ws { + let _ = writeln!(err, "warning: {w}"); + } +} + +/// The engine knows which type families aren't installed. Warn before an export +/// uses a fallback face rather than silently changing the appearance of the text. +pub(crate) fn missing_font_warnings(fonts: Vec) -> Vec { + fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect() +} + +pub(crate) 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())?; + writeln!(out, "{s}").map_err(|e| e.to_string()) +} From f59f1c347f7a2536e63a13f20b4f4a564050862d Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 10:43:00 +0300 Subject: [PATCH 12/40] refactor(cli): typed errors with thiserror instead of String (#7071) Every failure was formatted into a String where it happened, so callers could only print it. Subcommands now return a CliError, the actions loaders an ActionsError and the hand-written parser a UsageError; each variant prints exactly the text the String did, and the engine and automation errors pass through unchanged. Also: print_json takes a JsonStyle instead of a bool, the engine's missing_font_warnings replaces the CLI's copy (same text), batch warns through warn_all, run drops a second, no-op insert into its reported font set, and the src tests share one Invoke helper. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 1 + apps/photocraft-cli/src/actions.rs | 84 ++++++++++++++----- apps/photocraft-cli/src/args.rs | 49 ++++++++--- apps/photocraft-cli/src/automation.rs | 39 +++++---- apps/photocraft-cli/src/batch.rs | 80 ++++++++---------- apps/photocraft-cli/src/document.rs | 116 +++++++++++--------------- apps/photocraft-cli/src/error.rs | 53 ++++++++++++ apps/photocraft-cli/src/lib.rs | 23 ++++- apps/photocraft-cli/src/output.rs | 37 ++++---- 10 files changed, 300 insertions(+), 183 deletions(-) create mode 100644 apps/photocraft-cli/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index df709e58dc..ec45e1b0cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4741,6 +4741,7 @@ dependencies = [ "photocraft-io", "photocraft-raw", "serde_json", + "thiserror 2.0.21", "tokio", "winresource", ] diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 70f15d5be1..e3db4be1cd 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -29,6 +29,7 @@ photocraft-io = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 0e6ab01ff6..cb75d62cb3 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -1,18 +1,58 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use photocraft_engine::actions_cmds::Action; use serde_json::{Value, json}; +/// Why an actions file can't be played. +#[derive(Debug, thiserror::Error)] +pub enum ActionsError { + #[error("actions JSON: {0}")] + Json(#[source] serde_json::Error), + /// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]). + #[error(transparent)] + Steps(#[from] photocraft_engine::EngineError), + #[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")] + NotASet, + #[error("Actions set: {0}")] + Set(#[source] serde_json::Error), + #[error("no action named `{name}` in the set (it holds {})", quoted(names))] + NoSuchAction { name: String, names: Vec }, + #[error("the Actions set holds {} actions: pick one with --action ({})", names.len(), quoted(names))] + PickOne { names: Vec }, + #[error("{}: {source}", path.display())] + Read { path: PathBuf, source: std::io::Error }, + #[error("{}: {source}", path.display())] + Utf8 { path: PathBuf, source: std::string::FromUtf8Error }, + /// A Photoshop Actions file that doesn't import ([`photocraft_engine::atn_cmds::import`]). + #[error("{}: {message}", path.display())] + Atn { path: PathBuf, message: String }, + #[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))] + NoSuchAtnAction { name: String, set: String, names: Vec }, + #[error("{}: the set holds no actions", path.display())] + EmptyAtn { path: PathBuf }, + #[error("the set `{set}` holds {} actions: pick one with --action ({})", names.len(), quoted(names))] + PickOneAtn { set: String, names: Vec }, +} + +/// `` `a`, `b` ``: the action names an error lists. +fn quoted(names: &[String]) -> String { + names.iter().map(|n| format!("`{n}`")).collect::>().join(", ") +} + +fn names(actions: &[Action]) -> Vec { + actions.iter().map(|a| a.name.clone()).collect() +} + /// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs, /// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a /// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489). -pub fn parse_actions(text: &str) -> Result, String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; +pub fn parse_actions(text: &str) -> Result, ActionsError> { + let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?; steps_of(&v) } -fn steps_of(v: &Value) -> Result { - photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string()) +fn steps_of(v: &Value) -> Result { + Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?) } /// Engine commands to run in order: `(id, params)`. @@ -22,22 +62,21 @@ pub type Steps = Vec<(String, Value)>; /// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays /// the action `name` picks (optional when the set holds one) with the whole set loaded, as /// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). -pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; +pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), ActionsError> { + let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?; let list = v.get("actions").unwrap_or(&v); let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); if !is_set { if name.is_some() { - return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into()); + return Err(ActionsError::NotASet); } return Ok((steps_of(&v)?, Vec::new())); } - let set: Vec = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?; - let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let set: Vec = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?; let action = match (name, set.as_slice()) { - (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?, + (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?, (None, [only]) => only, - (None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action ({})", set.len(), names())), + (None, _) => return Err(ActionsError::PickOne { names: names(&set) }), }; let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; Ok((play, set)) @@ -46,23 +85,24 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), String> { - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; +pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec, Vec), ActionsError> { + let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.into(), source })?; let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); if !is_atn { - let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; + let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.into(), source })?; let (steps, set) = load_actions(&text, name)?; return Ok((steps, set, Vec::new())); } - let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; - let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.into(), message })?; let action = match (name, imp.actions.as_slice()) { - (Some(n), _) => { - imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? - } + (Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction { + name: n.to_owned(), + set: imp.set.clone(), + names: names(&imp.actions), + })?, (None, [only]) => only, - (None, []) => return Err(format!("{path}: the set holds no actions")), - (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), + (None, []) => return Err(ActionsError::EmptyAtn { path: path.into() }), + (None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }), }; let warnings = imp .unsupported diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index b567a4dcd7..df3d411d12 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -1,7 +1,7 @@ use std::io::Write; use crate::{ - R, + Result, automation::{commands, mcp, serve}, batch::{batch, droplet}, document::{convert, info, run_cmds}, @@ -75,7 +75,7 @@ pub(crate) struct Subcommand { pub(crate) name: &'static str, pub(crate) values: &'static [&'static str], pub(crate) bare: &'static [&'static str], - pub(crate) run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R, + pub(crate) run: fn(&Args, &mut dyn Write, &mut dyn Write) -> Result, } pub(crate) const SUBCOMMANDS: &[Subcommand] = &[ @@ -138,10 +138,33 @@ pub(crate) const SUBCOMMANDS: &[Subcommand] = &[ }, ]; +/// A command line [`parse`] refuses: printed with the usage text, exit 2. +#[derive(Debug, thiserror::Error)] +pub(crate) enum UsageError { + #[error("unknown flag {flag} for {sub}")] + UnknownFlag { flag: String, sub: &'static str }, + #[error("{0} takes no value")] + TakesNoValue(String), + #[error("{0} needs a value")] + NeedsValue(String), + #[error("{0} does not take positional arguments")] + Positional(&'static str), + #[error("{0} may only be specified once")] + Repeated(String), + #[error("{0} needs a non-empty value")] + EmptyValue(String), + #[error("use either --control-token or --control-token-file, not both")] + TokenAndTokenFile, + #[error("{flag} requires {transport}")] + Requires { flag: &'static str, transport: &'static str }, + #[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")] + BridgeRoots, +} + /// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`). -pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, String> { +pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, UsageError> { let mut a = Args { positional: Vec::new(), flags: Vec::new() }; - let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name); + let unknown = |flag: &str| UsageError::UnknownFlag { flag: flag.to_owned(), sub: sub.name }; let mut i = 0; while let Some(s) = args.get(i) { if s == "--help" || s == "-h" { @@ -149,7 +172,7 @@ pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, S } if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { if sub.bare.contains(&k) { - return Err(format!("{k} takes no value")); + return Err(UsageError::TakesNoValue(k.to_owned())); } if !sub.values.contains(&k) { return Err(unknown(k)); @@ -161,7 +184,7 @@ pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, S if v.is_some_and(|v| v == "--help" || v == "-h") { return Ok(None); } - let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| format!("{s} needs a value"))?; + let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| UsageError::NeedsValue(s.clone()))?; a.flags.push((s.clone(), Some(v.clone()))); i += 1; } else if sub.bare.contains(&s.as_str()) { @@ -179,33 +202,33 @@ pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, S /// Refuse launch options that would otherwise be silently ignored. In particular, bridge /// roots belong to the desktop process: accepting them here promises authority we cannot set. -fn validate_server_args(sub: &str, a: &Args) -> R { +fn validate_server_args(sub: &'static str, a: &Args) -> Result<(), UsageError> { if !matches!(sub, "mcp" | "serve") { return Ok(()); } if !a.positional.is_empty() { - return Err(format!("{sub} does not take positional arguments")); + return Err(UsageError::Positional(sub)); } let mut seen = std::collections::HashSet::new(); for (flag, value) in &a.flags { if !seen.insert(flag) { - return Err(format!("{flag} may only be specified once")); + return Err(UsageError::Repeated(flag.clone())); } if value.as_deref().is_none_or(str::is_empty) { - return Err(format!("{flag} needs a non-empty value")); + return Err(UsageError::EmptyValue(flag.clone())); } } if a.has("--control-token") && a.has("--control-token-file") { - return Err("use either --control-token or --control-token-file, not both".into()); + return Err(UsageError::TokenAndTokenFile); } let transport = if sub == "mcp" { "--bridge" } else { "--port" }; for flag in ["--control-token", "--control-token-file"] { if a.has(flag) && !a.has(transport) { - return Err(format!("{flag} requires {transport}")); + return Err(UsageError::Requires { flag, transport }); } } if sub == "mcp" && a.has("--bridge") && (a.has("--automation-read-root") || a.has("--automation-write-root")) { - return Err("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode".into()); + return Err(UsageError::BridgeRoots); } Ok(()) } diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index 064592b6f2..5c0c94430a 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -3,16 +3,19 @@ use std::path::{Path, PathBuf}; use serde_json::Value; -use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, security}; +use photocraft_automation::{AuthorizedWorkspace, AutomationError, Headless, PhotocraftMcp, security}; -use crate::{R, args::Args, output::print_json}; +use crate::{ + CliError, Result, + args::Args, + output::{JsonStyle, print_json}, +}; -pub(crate) fn automation_workspace(args: &Args) -> Result { +pub(crate) fn automation_workspace(args: &Args) -> Result { AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new)) - .map_err(|error| error.to_string()) } -pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> R { +pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> Result { let h = Headless::new(); let list = h.command_list(); let needle = a.get("--filter").map(str::to_lowercase); @@ -24,24 +27,24 @@ pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> R { .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, 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()), JsonStyle::Pretty); } for c in items { let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::>().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())?; + writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu)?; } Ok(()) } -pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> R { +pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> Result { use std::sync::{Arc, Mutex}; let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?))); match a.get("--port") { Some(port) => { - let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?; + let port: u16 = port.parse().map_err(|_| CliError::BadPort(port.to_owned()))?; let addr = format!("127.0.0.1:{port}"); let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; + let token = security::server_token(supplied.as_deref(), token_file.as_deref())?; if let Some(path) = &token_file { let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); } else if supplied.is_none() { @@ -49,25 +52,25 @@ pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> R { } photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| { let _ = writeln!(err, "photocraft-cli serving on {local}"); - }) - .map_err(|e| e.to_string()) + })?; + Ok(()) } None => { let stdin = std::io::stdin(); - photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) + Ok(photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())?) } } } -pub(crate) fn mcp(a: &Args) -> R { +pub(crate) fn mcp(a: &Args) -> Result { let server = match a.get("--bridge") { Some(addr) => { let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; - PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())? + let token = security::client_token(supplied.as_deref(), token_file.as_deref())?; + PhotocraftMcp::bridge(addr, &token)? } None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?), }; - 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()) + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + Ok(rt.block_on(server.serve_stdio())?) } diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index e02351d13b..2b14e04889 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -6,7 +6,7 @@ use photocraft_engine::actions_cmds::Action; use serde_json::json; use crate::{ - R, + CliError, Result, actions::load_actions_file, args::Args, output::{export_opts, warn_all}, @@ -46,27 +46,27 @@ fn strip_shell_steps(action: &mut Action) { (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); } -pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; - let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); - let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); +pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let needs = |what| CliError::Needs { sub: "batch", what }; + let actions_path = a.get("--actions").ok_or_else(|| needs("--actions "))?; + let in_dir = PathBuf::from(a.get("--in").ok_or_else(|| needs("--in "))?); + let out_dir = PathBuf::from(a.get("--out").ok_or_else(|| needs("--out "))?); let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; set.iter_mut().for_each(strip_shell_steps); - for w in &warnings { - let _ = writeln!(err, "warning: {w}"); - } + warn_all(err, &warnings); let opts = export_opts(a)?; // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). if !a.has("--in-place") && same_dir(&in_dir, &out_dir) { - return Err(format!( - "--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", - out_dir.display() - )); + return Err(CliError::OutIsIn(out_dir)); } - std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let mut inputs: Vec = - 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(); + std::fs::create_dir_all(&out_dir)?; + let mut inputs: Vec = std::fs::read_dir(&in_dir) + .map_err(|source| CliError::ReadDir { path: in_dir.clone(), source })? + .flatten() + .map(|e| e.path()) + .filter(|p| is_input(p)) + .collect(); inputs.sort(); let (mut ok, mut failed) = (0, 0); let mut written = photocraft_engine::file_cmds::OutputClaims::default(); @@ -77,24 +77,24 @@ pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let target = out_dir.join(format!("{stem}.{ext}")); let target_text = target.to_string_lossy(); - let r = (|| -> Result, String> { - written.check(&target_text)?; + let r = (|| -> Result> { + written.check(&target_text).map_err(CliError::OutputClaimed)?; let mut h = Headless::trusted_local(); // Steps that play another action find it in the set; the session's playback stack // stops an action that calls itself. h.session.actions.list = set.clone(); - h.open(input).map_err(|e| e.to_string())?; + h.open(input)?; for (id, p) in &actions { if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { continue; } - let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; + let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?; // A failed step inside a called action makes this file an error, not a saved result. - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) { - return Err(format!("`{id}`: {e}")); + if let Some(message) = photocraft_engine::actions_cmds::nested_failure(id, &r) { + return Err(CliError::NestedFailure { id: id.clone(), message }); } } - let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?; + let r = h.save(None, Some(&target), Some(&ext), &opts)?; Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) })(); match r { @@ -111,20 +111,21 @@ pub(crate) 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(CliError::FilesFailed(failed)) } else { Ok(()) } } -pub(crate) fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let (file, inputs) = a.positional.split_first().ok_or("droplet needs and inputs")?; +pub(crate) fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let needs = |what| CliError::Needs { sub: "droplet", what }; + let (file, inputs) = a.positional.split_first().ok_or_else(|| needs(" and inputs"))?; if inputs.is_empty() { - return Err("droplet needs at least one input file or folder".into()); + return Err(needs("at least one input file or folder")); } let mut p = json!({"droplet": file, "input": inputs}); if let Some(o) = a.get("--out") { 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)?; for f in r["files"].as_array().into_iter().flatten() { let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default()); } @@ -132,22 +133,15 @@ pub(crate) fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { 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())) } + if errors.is_empty() { Ok(()) } else { Err(CliError::FilesFailed(errors.len())) } } #[cfg(test)] mod batch_action_set_tests { use super::*; - use crate::run; + use crate::test_support::{Invoke, invoke}; use serde_json::Value; - fn invoke(args: &[&str]) -> (i32, String, String) { - let (mut output, mut errors) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) - } - /// A batched action that plays another action of its set finds it in every file's session; a /// failure inside the called action fails the file, and a self-call stops instead of looping. #[test] @@ -157,7 +151,7 @@ mod batch_action_set_tests { let path = |name: &str| dir.join(name).to_string_lossy().to_string(); std::fs::create_dir_all(dir.join("in")).unwrap(); for name in ["a", "b"] { - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); assert_eq!(code, 0, "{stderr}"); } let set = json!({"version": 1, "actions": [ @@ -170,16 +164,16 @@ mod batch_action_set_tests { std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); - let (code, stdout, stderr) = batch("Outer"); + let Invoke { code, stdout, stderr } = batch("Outer"); assert_eq!(code, 0, "{stdout}{stderr}"); for name in ["a", "b"] { - let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); + let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); assert_eq!(code, 0, "{stderr}"); let info: Value = serde_json::from_str(&info).unwrap(); assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action"); } for action in ["Calls bad", "Loop"] { - let (code, stdout, stderr) = batch(action); + let Invoke { code, stdout, stderr } = batch(action); assert_ne!(code, 0, "{stdout}"); assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}"); assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved"); @@ -195,7 +189,7 @@ mod batch_action_set_tests { let _ = std::fs::remove_dir_all(&dir); let path = |name: &str| dir.join(name).to_string_lossy().to_string(); std::fs::create_dir_all(dir.join("in")).unwrap(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); assert_eq!(code, 0, "{stderr}"); let set = json!({"version": 1, "actions": [ {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, @@ -209,10 +203,10 @@ mod batch_action_set_tests { ]}); std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); for action in ["Outer", "Rotate"] { - let (code, stdout, stderr) = + let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); assert_eq!(code, 0, "{stdout}{stderr}"); - let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); + let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); assert_eq!(code, 0, "{stderr}"); let info: Value = serde_json::from_str(&info).unwrap(); assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 81b6df406c..b453127e13 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -4,10 +4,12 @@ use std::path::Path; use photocraft_automation::{Headless, files}; use serde_json::{Value, json}; +use photocraft_engine::type_extra_cmds::missing_font_warnings; + use crate::{ - R, + CliError, Result, args::Args, - output::{export_opts, missing_font_warnings, print_json, warn_all}, + output::{JsonStyle, export_opts, print_json, warn_all}, }; /// An output that is the input file itself, however it is spelt (relative, with `./`, through a @@ -16,39 +18,39 @@ use crate::{ /// folder (#7437). The paths are compared when the command starts and the save opens `output` /// again later, so another process swapping in a link meanwhile goes unnoticed: this guards /// against mistakes, it is not a security boundary. -fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { +fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> Result { if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { - return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); + return Err(CliError::OutIsInput(output.to_owned())); } Ok(()) } -pub(crate) fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { +pub(crate) fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> Result { let [input, output] = a.positional.as_slice() else { - return Err("convert needs ".into()); + return Err(CliError::Needs { sub: "convert", what: " " }); }; let opts = export_opts(a)?; refuse_overwriting_input(a, input, output)?; - let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + let mut o = files::open(Path::new(input))?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); - warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; + warn_all(err, &missing_font_warnings(&o.document)); + let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None)?; warn_all(err, &ws); Ok(()) } -pub(crate) fn info(a: &Args, out: &mut dyn Write) -> R { +pub(crate) fn info(a: &Args, out: &mut dyn Write) -> Result { let [file] = a.positional.as_slice() else { - return Err("info needs ".into()); + return Err(CliError::Needs { sub: "info", what: "" }); }; let mut h = Headless::trusted_local(); - let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?; - let mut doc = h.inspect(None).map_err(|e| e.to_string())?; + let opened = h.open(Path::new(file))?; + let mut doc = h.inspect(None)?; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); if let Some(st) = h.session.active() { - warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String)); + warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String)); } doc["warnings"] = Value::Array(warnings); doc["file"] = json!(file); @@ -58,18 +60,18 @@ pub(crate) fn info(a: &Args, out: &mut dyn Write) -> R { m.remove(k); } } - print_json(out, &doc, a.has("--compact")) + print_json(out, &doc, if a.has("--compact") { JsonStyle::Compact } else { JsonStyle::Pretty }) } /// `(id, params)` pairs from repeated `--cmd`/`--params` flags. -fn command_list(a: &Args) -> Result, String> { +fn command_list(a: &Args) -> Result> { let mut cmds: Vec<(String, Value)> = Vec::new(); for (k, v) in &a.flags { match (k.as_str(), v) { ("--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))?; + let last = cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?; + last.1 = serde_json::from_str(p).map_err(|source| CliError::BadParams { id: last.0.clone(), source })?; } _ => {} } @@ -77,7 +79,7 @@ fn command_list(a: &Args) -> Result, String> { Ok(cmds) } -pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { +pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { let opts = export_opts(a)?; let mut h = Headless::trusted_local(); // Opening, running commands and exporting can all notice the same missing font: @@ -89,54 +91,41 @@ pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R if let Some(out) = a.get("--out") { refuse_overwriting_input(a, file, out)?; } - let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; + let o = h.open(Path::new(file))?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); - reported_fonts.extend(ws.iter().cloned()); warn_all(err, &ws); reported_fonts.extend(ws); } ([], Some(new)) => { - let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; - h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?; + let p: Value = serde_json::from_str(new).map_err(CliError::BadNew)?; + h.command_run("file.new", p).map_err(CliError::New)?; } - _ => return Err("run needs exactly one of or --new ".into()), + _ => return Err(CliError::Needs { sub: "run", what: "exactly one of or --new " }), } let cmds = command_list(a)?; if cmds.is_empty() && a.get("--out").is_none() { - return Err("run needs at least one --cmd (or --out)".into()); + return Err(CliError::Needs { sub: "run", what: "at least one --cmd (or --out)" }); } for (id, params) in cmds { - let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { - return Err(format!("`{id}`: {e}")); + let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?; + if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { + return Err(CliError::NestedFailure { id, message }); } - let new_warnings: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); + let new_warnings: Vec = + h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &new_warnings); let mut result = json!({"command": id, "result": r}); if !new_warnings.is_empty() { result["warnings"] = json!(new_warnings); } - print_json(out, &result, true)?; + print_json(out, &result, JsonStyle::Compact)?; } // A `run` with only `--out` still reports missing fonts before exporting. - let remaining: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); + let remaining: Vec = + h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &remaining); if let Some(o) = a.get("--out") { - let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; + let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts)?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); @@ -147,15 +136,10 @@ pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R #[cfg(test)] mod missing_font_warning_tests { use super::*; - use crate::{batch::is_input, run}; - - fn invoke(args: &[&str]) -> (i32, String, String) { - let mut output = Vec::new(); - let mut errors = Vec::new(); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) - } + use crate::{ + batch::is_input, + test_support::{Invoke, invoke}, + }; #[test] fn missing_typeface_is_reported_by_run_info_and_convert() { @@ -168,7 +152,7 @@ mod missing_font_warning_tests { let png_str = png.to_string_lossy().to_string(); let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string(); - let (code, stdout, stderr) = invoke(&[ + let Invoke { code, stdout, stderr } = invoke(&[ "run", "--new", r#"{"width":256,"height":96,"background":"white"}"#, @@ -188,19 +172,19 @@ mod missing_font_warning_tests { assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported"); - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); + let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]); assert_eq!(code, 0, "{stderr}"); let info: Value = serde_json::from_str(&stdout).unwrap(); assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr"); - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); + let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]); assert_eq!(code, 0, "{stderr}"); assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}"); assert!(png.is_file()); // Even with no commands, run --out checks the opened document before export. - let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]); + let Invoke { code, stdout, stderr } = invoke(&["run", &native_str, "--out", &png_str]); assert_eq!(code, 0, "{stderr}"); assert!(stdout.is_empty()); assert_eq!(stderr.matches(family).count(), 1); @@ -214,15 +198,15 @@ mod missing_font_warning_tests { std::fs::create_dir_all(&folder).unwrap(); let path = |name: &str| folder.join(name).to_string_lossy().to_string(); let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png")); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); assert_eq!(code, 0, "{stderr}"); - let (code, _, stderr) = invoke(&["convert", &native, &ora]); + let Invoke { code, stderr, .. } = invoke(&["convert", &native, &ora]); assert_eq!(code, 0, "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]); + let Invoke { code, stdout, stderr } = invoke(&["info", &ora, "--compact"]); assert_eq!(code, 0, "{stderr}"); let info: Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}"); - let (code, _, stderr) = invoke(&["convert", &ora, &png]); + let Invoke { code, stderr, .. } = invoke(&["convert", &ora, &png]); assert_eq!(code, 0, "{stderr}"); assert!(is_input(std::path::Path::new(&ora))); // Krita documents are batch inputs too (import-only). @@ -241,14 +225,14 @@ mod missing_font_warning_tests { let png = folder.join("blank.png"); let native_str = native.to_string_lossy().to_string(); let png_str = png.to_string_lossy().to_string(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); assert_eq!(code, 0, "{stderr}"); assert!(!stderr.contains("font '"), "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); + let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]); assert_eq!(code, 0, "{stderr}"); let info: Value = serde_json::from_str(&stdout).unwrap(); assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '"))); - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); + let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]); assert_eq!(code, 0, "{stderr}"); assert!(!stderr.contains("font '"), "{stderr}"); let _ = std::fs::remove_dir_all(&folder); diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs new file mode 100644 index 0000000000..99cbd7574f --- /dev/null +++ b/apps/photocraft-cli/src/error.rs @@ -0,0 +1,53 @@ +use std::path::PathBuf; + +use photocraft_automation::AutomationError; + +use crate::actions::ActionsError; + +/// Why a subcommand failed: printed as `error: {e}`, exit 1. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CliError { + #[error(transparent)] + Automation(#[from] AutomationError), + #[error(transparent)] + Engine(#[from] photocraft_engine::EngineError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Actions(#[from] ActionsError), + /// A required argument is missing: `convert needs `. + #[error("{sub} needs {what}")] + Needs { sub: &'static str, what: &'static str }, + #[error("bad {flag} `{value}`: expected {expected}")] + BadValue { flag: &'static str, value: String, expected: &'static str }, + #[error("bad --port `{0}`")] + BadPort(String), + #[error("bad --new JSON: {0}")] + BadNew(#[source] serde_json::Error), + #[error("bad --params JSON for `{id}`: {source}")] + BadParams { id: String, source: serde_json::Error }, + #[error("{}: {source}", path.display())] + ReadDir { path: PathBuf, source: std::io::Error }, + #[error("--params must follow a --cmd")] + ParamsBeforeCmd, + #[error("--new: {0}")] + New(#[source] AutomationError), + #[error("`{id}`: {source}")] + Command { id: String, source: AutomationError }, + /// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]). + #[error("`{id}`: {message}")] + NestedFailure { id: String, message: String }, + #[error("{0} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")] + OutIsInput(String), + #[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())] + OutIsIn(PathBuf), + /// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]). + #[error("{0}")] + OutputClaimed(String), + #[error("{0} file(s) failed")] + FilesFailed(usize), +} + +pub(crate) type Result = std::result::Result; diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index c04838086e..276a924ca5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -10,13 +10,13 @@ mod args; mod automation; mod batch; mod document; +mod error; mod output; -pub use actions::{Steps, load_actions, load_actions_file, parse_actions}; +pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions}; pub use args::USAGE; use args::{SUBCOMMANDS, parse}; - -pub(crate) type R = Result<(), String>; +pub(crate) use error::{CliError, Result}; /// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error /// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts @@ -76,3 +76,20 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { } } } + +#[cfg(test)] +pub(crate) mod test_support { + /// What [`crate::run`] returned and wrote. + pub(crate) struct Invoke { + pub(crate) code: i32, + pub(crate) stdout: String, + pub(crate) stderr: String, + } + + pub(crate) fn invoke(args: &[&str]) -> Invoke { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let code = crate::run(&args, &mut stdout, &mut stderr); + Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } + } +} diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 973d75d9cb..f106e92c13 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -2,40 +2,38 @@ use photocraft_io::ExportOptions; use serde_json::Value; use std::io::Write; -use crate::{R, args::Args}; +use crate::{CliError, Result, args::Args}; -pub(crate) fn export_opts(a: &Args) -> Result { +pub(crate) fn export_opts(a: &Args) -> Result { + let bad = |flag, value: &str, expected| CliError::BadValue { flag, value: value.to_owned(), expected }; // TIFF output is flat unless asked: scripted conversions keep predictable sizes. let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() }; if let Some(q) = a.get("--quality") { - let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?; + let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| bad("--quality", q, "a whole number from 1 to 100"))?; o.encode.jpeg_quality = q; // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. o.encode.webp_quality = q; o.encode.webp_lossless = false; } if let Some(b) = a.get("--tga-bits") { - o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); + o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| bad("--tga-bits", b, "24 or 32"))?); } if let Some(b) = a.get("--dpx-bits") { - o.encode.dpx_bits = - Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| bad("--dpx-bits", b, "8, 10, 12 or 16"))?); } if let Some(c) = a.get("--tiff-compression") { o.encode.tiff_compression = match c { "none" => photocraft_codecs::TiffCompression::None, "lzw" => photocraft_codecs::TiffCompression::Lzw, "zip" => photocraft_codecs::TiffCompression::Deflate, - _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + _ => return Err(bad("--tiff-compression", c, "none, lzw or zip")), }; } if let Some(f) = a.get("--jpeg-format") { - o.encode.jpeg_format = - photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; + o.encode.jpeg_format = photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| bad("--jpeg-format", f, "baseline, optimized or progressive"))?; } if let Some(c) = a.get("--dds-compression") { - o.encode.dds.compression = - photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; + o.encode.dds.compression = photocraft_io::DdsCompression::from_name(c).ok_or_else(|| bad("--dds-compression", c, "none, bc1, bc2 or bc3"))?; } o.encode.dds.mipmaps = a.has("--dds-mipmaps"); Ok(o) @@ -47,13 +45,16 @@ pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) { } } -/// The engine knows which type families aren't installed. Warn before an export -/// uses a fallback face rather than silently changing the appearance of the text. -pub(crate) fn missing_font_warnings(fonts: Vec) -> Vec { - fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect() +/// How [`print_json`] lays the JSON out. +pub(crate) enum JsonStyle { + Pretty, + Compact, } -pub(crate) 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())?; - writeln!(out, "{s}").map_err(|e| e.to_string()) +pub(crate) fn print_json(out: &mut dyn Write, v: &Value, style: JsonStyle) -> Result { + let s = match style { + JsonStyle::Pretty => serde_json::to_string_pretty(v), + JsonStyle::Compact => serde_json::to_string(v), + }?; + Ok(writeln!(out, "{s}")?) } From 5ca6fc776793822ecb2e396b2592a141beb85aea Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 18:35:23 +0200 Subject: [PATCH 13/40] PSD export: embed a linked file only when it is an image PhotoCraft can open A PSD/PSB/layered-TIFF save read every linked smart object's file and embedded it, even when no importer can decode it (a text or settings file the link happened to point at). Such a layer can't show its contents in any reader. A linked file is now embedded only when it decodes; otherwise the layer is written as pixels with a warning. Embedded sources are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/doc_path/tests.rs | 32 +++++++++++++++++++++++++++++ crates/io/src/psd_export.rs | 23 +++++++++++++-------- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 1881860ae1..441ba5e702 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -255,3 +255,35 @@ fn data_sets_and_footage_read_only_followed_files() { assert!(err.contains("is not opened: the path is on another computer"), "{err}"); footage(&mut s, "blue.png").unwrap(); } + +/// A link to a file PhotoCraft can't place (a text or settings file) is never embedded: a saved +/// PSD would otherwise carry bytes no reader can show as the layer's contents. The layer is +/// written as pixels; Convert to Embedded and Relink refuse. +#[test] +fn links_to_files_that_are_not_images_are_never_embedded() { + const TEXT: &[u8] = b"plain text, not an image +photocraft-text-marker +"; + let d = dir("not-image"); + std::fs::create_dir_all(d.join("notes")).unwrap(); + std::fs::create_dir_all(d.join("Downloads")).unwrap(); + std::fs::write(d.join("notes").join("readme"), TEXT).unwrap(); + std::fs::write(d.join("Downloads").join(".settings"), TEXT).unwrap(); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + s.active_mut().unwrap().path = Some(d.join("Downloads").join("doc.pcraft").to_string_lossy().into_owned()); + let has_text = |bytes: &[u8]| bytes.windows(TEXT.len()).any(|w| w == TEXT); + for (n, path) in ["../notes/readme", ".settings"].into_iter().enumerate() { + relink(&mut s, id, path); + for ext in ["psd", "psb", "tif"] { + let out = d.join(format!("copy{n}.{ext}")); + let r = s.execute("file.saveACopy", json!({"path": out.to_string_lossy(), "tiffLayers": true})).unwrap(); + assert!(!has_text(&std::fs::read(&out).unwrap()), "{path} embedded in the {ext}: {r}"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("written as pixels"), "{path} {ext}: {warnings}"); + } + assert!(s.execute("layer.smartObjects.convertToEmbedded", json!({"layer": id.0})).is_err(), "{path}"); + let abs = d.join("notes").join("readme"); + assert!(s.execute("layer.smartObjects.relinkToFile", json!({"layer": id.0, "path": abs.to_string_lossy()})).is_err(), "{path}"); + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 5fe443748c..20000e32dd 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1005,13 +1005,14 @@ impl Ex { let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?; let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); + let source = self.embed(sm.contents_id, &name, &bytes, true)?; self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); - self.embed(sm.contents_id, &name, &bytes) + Ok(source) } #[cfg(target_arch = "wasm32")] Err(format!("the linked file {path} can't be read here")) } - SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes), + SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes, false), } } @@ -1056,17 +1057,19 @@ impl Ex { /// Adds `bytes` as an embedded file (converted to PSB when it is a `.pcraft` bundle), once /// per contents identity and bytes. Equal bytes in independent objects stay independent. - fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result { + /// `linked`: the bytes were read from a path the document names, so only a file PhotoCraft + /// can open is embedded (a link to a text or settings file is written as pixels instead). + fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { let key = (contents_id, *blake3::hash(bytes).as_bytes()); if let Some(r) = self.smart.converted.get(&key) { return r.clone(); } - let r = self.convert_source(contents_id, file_name, bytes); + let r = self.convert_source(contents_id, file_name, bytes, linked); self.smart.converted.insert(key, r.clone()); r } - fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result { + fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { let (name, data, size, dpi, nested) = if photocraft_format::is_pcraft(bytes) { if self.smart.depth >= MAX_NESTING { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); @@ -1084,9 +1087,13 @@ impl Ex { let size = (f64::from(doc.size.width), f64::from(doc.size.height)); (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) } else { - // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. - let (size, dpi) = - crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + // Embedded formats we can't decode (vector PDF/AI…) are kept; their size comes from the + // layer. A linked file we can't decode is not embedded. + let (size, dpi) = match crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE) { + Ok(geometry) => geometry, + Err(e) if linked => return Err(format!("the linked file {file_name} is not an image PhotoCraft can open: {e}")), + Err(_) => ((0.0, 0.0), f64::from(self.dpi)), + }; (file_name.to_string(), bytes.to_vec(), size, dpi, None) }; let uuid = contents_uuid(contents_id, &data); From 3651f6c2287e6bf44cdd4471c934765dd4e38e50 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 10:43:01 +0300 Subject: [PATCH 14/40] test(cli): name the fields of test run results (#7071) invoke returned (code, stdout, stderr) and ok (stdout, stderr), so each call site had to remember the order. Return Invoke and Printed structs instead. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/tests/it/cli.rs | 114 ++++++++++++++++------------ 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 61778d0fd3..7b4402de12 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -23,11 +23,17 @@ fn write_png(path: &Path, w: u32, h: u32, seed: u8) { std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); } -fn ok(cmd: &mut Command) -> (String, String) { +/// What a successful run printed. +struct Printed { + stdout: String, + stderr: String, +} + +fn ok(cmd: &mut Command) -> Printed { let o = cmd.output().unwrap(); - let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); - assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status); - (out, err) + let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); + assert!(o.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", o.status); + Printed { stdout, stderr } } #[test] @@ -37,9 +43,9 @@ fn usage_and_unknown_command() { assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE")); let o = bin().arg("frobnicate").output().unwrap(); assert_eq!(o.status.code(), Some(2)); - let (out, _) = ok(bin().arg("--help")); + let out = ok(bin().arg("--help")).stdout; assert!(out.contains("convert")); - let (out, _) = ok(bin().arg("--version")); + let out = ok(bin().arg("--version")).stdout; assert!(out.starts_with("photocraft-cli ")); } @@ -110,11 +116,11 @@ fn unknown_flags_are_usage_errors() { // `--flag=value` still passes a value that starts with `--`. ok(bin().args(["commands", "--filter=--json"])); // Every flag still works in both forms. - let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); + let out = ok(bin().args(["commands", "--json", "--filter=gaussian"])).stdout; assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). for needle in ["ellipse", "marquee"] { - let (out, _) = ok(bin().args(["commands", "--filter", needle])); + let out = ok(bin().args(["commands", "--filter", needle])).stdout; assert!(out.contains("select.rect"), "{needle}: {out}"); } ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ @@ -123,7 +129,7 @@ fn unknown_flags_are_usage_errors() { "80", ])); assert!(out_dir.join("a.jpg").exists()); - let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")); + let out = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")).stdout; assert!(!out.trim().contains('\n'), "compact JSON is one line"); } @@ -234,14 +240,14 @@ fn info_prints_layer_tree_json() { let d = tmp("info"); let a = d.join("a.png"); write_png(&a, 10, 6, 3); - let (out, _) = ok(bin().arg("info").arg(&a)); + let out = ok(bin().arg("info").arg(&a)).stdout; let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["width"], 10); assert_eq!(v["height"], 6); // An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop). assert_eq!(v["layers"][0]["name"], "Layer 0"); assert!(v.get("history").is_none()); - let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact")); + let out = ok(bin().arg("info").arg(&a).arg("--compact")).stdout; assert_eq!(out.trim().lines().count(), 1); std::fs::remove_dir_all(d).unwrap(); } @@ -270,10 +276,10 @@ fn info_and_convert_report_decode_warnings() { for (name, bytes, warning, export) in cases { let file = d.join(name); std::fs::write(&file, bytes).unwrap(); - let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact")); + let out = ok(bin().arg("info").arg(&file).arg("--compact")).stdout; let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}"); - let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))); + let err = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))).stderr; let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect(); assert_eq!( err.trim(), @@ -291,18 +297,19 @@ fn info_and_convert_report_decode_warnings() { fn run_commands_and_save() { let d = tmp("run"); let out_pc = d.join("r.pcraft"); - let (out, _) = ok(bin() + let out = ok(bin() .args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#]) .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]) .args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##]) .args(["--cmd", "document.inspect"]) .arg("--out") - .arg(&out_pc)); + .arg(&out_pc)) + .stdout; let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[2]["command"], "document.inspect"); assert_eq!(lines[2]["result"]["name"], "Run"); - let (info, _) = ok(bin().arg("info").arg(&out_pc)); + let info = ok(bin().arg("info").arg(&out_pc)).stdout; let v: Value = serde_json::from_str(&info).unwrap(); // Saving a native document gives the reopened bundle its output filename. assert_eq!(v["name"], "r.pcraft"); @@ -343,13 +350,14 @@ fn run_stops_after_failed_action_without_exporting() { fn run_continues_after_successful_action_and_exports() { let d = tmp("run-successful-action"); let out = d.join("result.png"); - let (stdout, _) = ok(bin() + let stdout = ok(bin() .args(["run", "--new", r#"{"width":8,"height":8}"#]) .args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#]) .args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"]) .args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#]) .args(["--cmd", "document.inspect", "--out"]) - .arg(&out)); + .arg(&out)) + .stdout; let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap(); assert_eq!(playback["result"]["ran"], 1); @@ -407,10 +415,10 @@ fn batch_applies_actions_to_directory() { std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); let actions = d.join("actions.json"); std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); - let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])); + let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout; assert!(out.contains("2 succeeded, 0 failed"), "{out}"); for n in ["one", "two"] { - let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))); + let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout; let v: Value = serde_json::from_str(&info).unwrap(); assert_eq!(v["layers"][0]["name"], "Batch"); } @@ -444,7 +452,7 @@ fn batch_skips_recorded_view_and_save_steps() { assert!(o.status.success(), "{name}: {stdout}\n{stderr}"); assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}"); let output = d.join(name).join("a.png"); - let (info, _) = ok(bin().arg("info").arg(&output)); + let info = ok(bin().arg("info").arg(&output)).stdout; let info: Value = serde_json::from_str(&info).unwrap(); assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}"); assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input"); @@ -585,7 +593,7 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { let bytes = std::fs::read(&out).unwrap(); assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); } - let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); + let info = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")).stdout; let v: Value = serde_json::from_str(&info).unwrap(); assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); @@ -695,7 +703,7 @@ fn run_refuses_to_write_over_its_input() { // `--in-place` opts in: the inverted result replaces the original, which still opens. ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"])); assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged"); - let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let out = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])).stdout; let info: Value = serde_json::from_str(&out).unwrap(); assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); } @@ -762,7 +770,7 @@ fn batch_refuses_to_write_over_its_inputs() { } } // A separate folder runs as before, leaving the originals alone. - let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])); + let out = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])).stdout; assert!(out.contains("2 succeeded, 0 failed"), "{out}"); assert_eq!(snapshot(), before); // `--in-place` opts in: each result replaces its original. @@ -804,16 +812,16 @@ fn convert_refuses_to_write_over_its_input() { assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); // `--in-place` opts in: the result replaces the original, which still opens. ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"])); - let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let Printed { stdout: out, .. } = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); let info: Value = serde_json::from_str(&out).unwrap(); assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); } #[test] fn commands_listing() { - let (out, _) = ok(bin().arg("commands")); + let out = ok(bin().arg("commands")).stdout; assert!(out.contains("file.new")); - let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"])); + let out = ok(bin().args(["commands", "--json", "--filter", "layer.new"])).stdout; let v: Value = serde_json::from_str(&out).unwrap(); assert!( v.as_array() @@ -898,7 +906,7 @@ fn batch_fails_inputs_that_share_an_output_name() { assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1); // Keeping each file's format, every input gets its own output. let out_dir = d.join("same"); - let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)); + let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout; assert!(out.contains("3 succeeded, 0 failed"), "{out}"); } @@ -914,7 +922,7 @@ fn droplet_runs_an_action_on_files() { ) .unwrap(); let out_dir = d.join("out"); - let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)); + let out = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)).stdout; assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}"); let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap(); assert_eq!(img.dimensions(), (4, 8)); @@ -926,7 +934,7 @@ fn droplet_runs_an_action_on_files() { fn tiff_output_is_flat_unless_tiff_layers_is_given() { let d = tmp("tiff-layers"); let layer_count = |path: &std::path::Path| -> usize { - let (info, _) = ok(bin().arg("info").arg(path)); + let info = ok(bin().arg("info").arg(path)).stdout; let v: Value = serde_json::from_str(&info).unwrap(); v["layers"].as_array().unwrap().len() }; @@ -987,7 +995,7 @@ fn batch_plays_a_photoshop_actions_file() { let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); assert!(!o.status.success()); assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); - let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)); + let err = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)).stderr; assert!(out_dir.join("a.png").exists()); assert!(!err.contains("warning"), "{err}"); // The unsupported step is warned about, and fails the file as Photoshop would stop on it. @@ -997,12 +1005,18 @@ fn batch_plays_a_photoshop_actions_file() { assert!(!o.status.success()); } -/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`. -fn invoke(args: &[&str]) -> (i32, String, String) { - let (mut out, mut err) = (Vec::new(), Vec::new()); +/// What `photocraft_cli::run` returned and wrote, in-process. +struct Invoke { + code: i32, + stdout: String, + stderr: String, +} + +fn invoke(args: &[&str]) -> Invoke { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = photocraft_cli::run(&args, &mut out, &mut err); - (code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap()) + let code = photocraft_cli::run(&args, &mut stdout, &mut stderr); + Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } } const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; @@ -1010,18 +1024,18 @@ const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "c /// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr. #[test] fn top_level_help_version_and_no_arguments() { - let (code, out, err) = invoke(&[]); + let Invoke { code, stdout: out, stderr: err } = invoke(&[]); assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false)); for help in ["--help", "-h", "help"] { - let (code, out, err) = invoke(&[help]); + let Invoke { code, stdout: out, stderr: err } = invoke(&[help]); assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}"); } for version in ["--version", "version"] { - let (code, out, err) = invoke(&[version]); + let Invoke { code, stdout: out, stderr: err } = invoke(&[version]); assert_eq!((code, err.as_str()), (0, ""), "{version}"); assert!(out.starts_with("photocraft-cli "), "{version}: {out}"); } - let (code, out, err) = invoke(&["frobnicate"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]); assert_eq!((code, out.is_empty()), (2, true)); assert!(err.contains("frobnicate"), "{err}"); } @@ -1032,7 +1046,7 @@ fn top_level_help_version_and_no_arguments() { fn every_subcommand_help_returns_on_stdout() { for sub in SUBCOMMANDS { for help in ["--help", "-h"] { - let (code, out, err) = invoke(&[sub, help]); + let Invoke { code, stdout: out, stderr: err } = invoke(&[sub, help]); assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}"); // `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't. assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}"); @@ -1046,7 +1060,7 @@ fn every_subcommand_rejects_an_unknown_flag() { for sub in SUBCOMMANDS { for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] { let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect(); - let (code, out, err) = invoke(&args); + let Invoke { code, stdout: out, stderr: err } = invoke(&args); assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}"); assert!(err.contains("--fromat"), "{args:?}: {err}"); } @@ -1057,23 +1071,23 @@ fn every_subcommand_rejects_an_unknown_flag() { #[test] fn flag_value_forms_and_repeats() { let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::>(); - let (code, eq, err) = invoke(&["commands", "--filter=file.new"]); + let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]); assert_eq!(code, 0, "{err}"); - let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]); + let spaced = invoke(&["commands", "--filter", "file.new"]).stdout; assert_eq!(eq, spaced); assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}"); // The last of a repeated value flag wins, whichever order they come in. - let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]); assert_eq!(code, 0, "{err}"); assert_eq!(out, eq); - let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]); assert_eq!(code, 0, "{err}"); assert!(out.is_empty(), "{out}"); // A bare flag takes no value; a value flag needs one. - let (code, out, err) = invoke(&["commands", "--json=1"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]); assert_eq!((code, out.is_empty()), (2, true)); assert!(err.contains("--json"), "{err}"); - let (code, out, err) = invoke(&["commands", "--filter"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]); assert_eq!((code, out.is_empty()), (2, true)); assert!(err.contains("--filter"), "{err}"); } @@ -1082,7 +1096,7 @@ fn flag_value_forms_and_repeats() { /// `--params` ahead of every `--cmd` fails naming `--params`. #[test] fn run_pairs_params_with_the_preceding_cmd() { - let (code, out, err) = invoke(&[ + let Invoke { code, stdout: out, stderr: err } = invoke(&[ "run", "--new", r#"{"width":8,"height":8}"#, @@ -1110,7 +1124,7 @@ fn run_pairs_params_with_the_preceding_cmd() { // Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params // with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead. assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}"); - let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); assert_ne!(code, 0); assert!(err.contains("--params"), "{err}"); } From 43476581ea292d66411d8f391bcaf8e99d593aa8 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:01:09 +0300 Subject: [PATCH 15/40] refactor(cli): parse photocraft-cli arguments with clap derive (#7071) Replace the hand-rolled parser (USAGE, SUBCOMMANDS, Args::get/has) with clap derive types in args.rs: a Cli parser, one Args struct per subcommand, and flattened ExportArgs (convert, run, batch) and ControlArgs (mcp, serve). The USAGE prose moves into doc comments, so -h and --help carry it. Handlers keep their bodies; only their argument type and the lines reading flags change. Behaviour is kept: the last of a repeated flag wins (args_override_self), values stay strings checked by the handlers with the same messages and exit 1, a value flag never takes the next flag as its value, and help there (`--out --help`) is still help, --version/version are checked before clap, help goes to stdout with exit 0 and usage errors to stderr with exit 2. run pairs each --params with the --cmd before it by putting both back in command-line order with ArgMatches::indices_of. mcp and serve keep #8035's launch checks: no positional arguments, no repeated or empty options, --control-token or --control-token-file but not both, a token flag only with --bridge or --port, and no automation roots with --bridge. clap gives the first four (a repeat is found by scanning the arguments while args_override_self stays on); a small check after parsing gives the last two, with #8035's messages. Behaviour changes (allowed by the issue's step 2): - Help and error text is clap's: per-subcommand help instead of the one USAGE page, "unexpected argument '--fromat' found" instead of "unknown flag --fromat for batch", and so on. - A wrong positional count is a usage error (exit 2) instead of a handler error (exit 1): convert with one or three paths, info with none, droplet with no inputs, run with two files. - A positional starting with '-' (other than "-") needs a '--' before it, and '--' now ends the flags instead of being an unknown flag. - A value starting with a single '-' is written --out=-image.png (or ./-image.png), as one starting with '--' already was; --quality and --filter keep negative numbers (allow_negative_numbers). - `help ` prints that subcommand's help. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 84 +++++ Cargo.toml | 1 + apps/photocraft-cli/Cargo.toml | 1 + apps/photocraft-cli/src/args.rs | 485 ++++++++++++++------------ apps/photocraft-cli/src/automation.rs | 28 +- apps/photocraft-cli/src/batch.rs | 28 +- apps/photocraft-cli/src/document.rs | 62 ++-- apps/photocraft-cli/src/lib.rs | 58 ++- apps/photocraft-cli/src/output.rs | 22 +- apps/photocraft-cli/tests/it/cli.rs | 28 +- 10 files changed, 453 insertions(+), 344 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec45e1b0cc..5c37fc0b8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,12 +241,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -993,6 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1001,8 +1046,22 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1079,6 +1138,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -3066,6 +3131,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -4403,6 +4474,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -4734,6 +4811,7 @@ dependencies = [ name = "photocraft-cli" version = "0.15.1" dependencies = [ + "clap", "photocraft-automation", "photocraft-codecs", "photocraft-engine", @@ -7544,6 +7622,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/Cargo.toml b/Cargo.toml index 8a3e05181e..b1a3417c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +clap = { version = "4", features = ["derive"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index e3db4be1cd..f646b5eb03 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -28,6 +28,7 @@ photocraft-engine = { workspace = true } photocraft-io = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } +clap = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index df3d411d12..e5503e35f5 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -1,243 +1,280 @@ -use std::io::Write; +//! The command line, as clap types. Values stay strings: the handlers check them, with their own +//! messages (#7071). A flag a subcommand doesn't take is a usage error, so a typo such as +//! `--fromat` can't be silently ignored (#423). A value flag never takes the next flag as its +//! value: a value starting with `-` is written `--out=-image.png` (or `./-image.png`); negative +//! numbers are kept where they mean something. -use crate::{ - Result, - automation::{commands, mcp, serve}, - batch::{batch, droplet}, - document::{convert, info, run_cmds}, -}; +use clap::builder::NonEmptyStringValueParser; +use clap::error::ErrorKind; +use clap::{ArgAction, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; -pub const USAGE: &str = "\ -photocraft-cli: headless PhotoCraft - -USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - An that is the file is refused, as the result would replace the original; - --in-place allows it. - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); - --tiff-compression compresses it (none, the default; lzw; zip). - --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. - --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as - its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. - --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. - --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 - (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. - --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized - (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so - only Gray and CMYK shrink) or progressive. - photocraft-cli info [--compact] - Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Open a file, run engine commands in order, save the result. Each --params - applies to the preceding --cmd. Prints each command's JSON result. An --out that is the - is refused, as the result would replace the original; --in-place allows it. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a - set: its action --action names plays, as below. JSON steps are [id, params] pairs, - {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them - (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions - panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed - when the set holds more than one) with the whole set loaded, so its steps can play other actions - of the set. An --out folder that is the --in folder is refused, as the results would replace - the originals; --in-place allows it. - photocraft-cli droplet … [--out ] - Run a droplet (File › Automate › Create Droplet) on images and folders. - photocraft-cli commands [--json] [--filter ] - List the engine command registry. - photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `). - Headless: each root grants that direction of file access; an omitted root denies it. - Prefer absolute root paths in MCP client configurations. - Bridge: set roots on the desktop app; CLI root flags are rejected. Explicit token flags - require --bridge; credentials can also use PHOTOCRAFT_CONTROL_TOKEN or PHOTOCRAFT_CONTROL_TOKEN_FILE. - photocraft-cli serve [--port ] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio, - or on 127.0.0.1:. Methods: engine.execute, jobs.list/cancel, engine.commands, - doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods - (docs/control-protocol.md#headless-server). - Explicit token flags require --port. Stdio needs no control token. - - photocraft-cli --help (or -h) prints this text. A flag the subcommand doesn't take is - an error. mcp and serve take no positional arguments; their options cannot be repeated or empty. -"; - -pub(crate) struct Args { - pub(crate) positional: Vec, - pub(crate) flags: Vec<(String, Option)>, +/// photocraft-cli: headless PhotoCraft +#[derive(Parser)] +#[command(name = "photocraft-cli", arg_required_else_help = true, args_override_self = true)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Command, } -/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag -/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423). -pub(crate) struct Subcommand { - pub(crate) name: &'static str, - pub(crate) values: &'static [&'static str], - pub(crate) bare: &'static [&'static str], - pub(crate) run: fn(&Args, &mut dyn Write, &mut dyn Write) -> Result, +#[derive(Subcommand)] +pub(crate) enum Command { + Convert(ConvertArgs), + Info(InfoArgs), + Run(RunArgs), + Batch(BatchArgs), + Droplet(DropletArgs), + Commands(CommandsArgs), + Mcp(McpArgs), + Serve(ServeArgs), } -pub(crate) const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { - name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: convert, - }, - Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, - Subcommand { - name: "run", - values: &[ - "--new", - "--cmd", - "--params", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: run_cmds, - }, - Subcommand { - name: "batch", - values: &[ - "--actions", - "--action", - "--in", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: batch, - }, - Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, - Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) }, - Subcommand { - name: "mcp", - values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, _| mcp(a), - }, - Subcommand { - name: "serve", - values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, err| serve(a, err), - }, -]; - -/// A command line [`parse`] refuses: printed with the usage text, exit 2. -#[derive(Debug, thiserror::Error)] -pub(crate) enum UsageError { - #[error("unknown flag {flag} for {sub}")] - UnknownFlag { flag: String, sub: &'static str }, - #[error("{0} takes no value")] - TakesNoValue(String), - #[error("{0} needs a value")] - NeedsValue(String), - #[error("{0} does not take positional arguments")] - Positional(&'static str), - #[error("{0} may only be specified once")] - Repeated(String), - #[error("{0} needs a non-empty value")] - EmptyValue(String), - #[error("use either --control-token or --control-token-file, not both")] - TokenAndTokenFile, - #[error("{flag} requires {transport}")] - Requires { flag: &'static str, transport: &'static str }, - #[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")] - BridgeRoots, +// How an image is written: shared by `convert`, `run` and `batch`. +#[derive(Args)] +pub(crate) struct ExportArgs { + /// Output format, overriding the output's extension (png, jpg, tif, webp, …) + #[arg(long, value_name = "EXT")] + pub(crate) format: Option, + /// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless + #[arg(long, value_name = "1-100", allow_negative_numbers = true)] + pub(crate) quality: Option, + /// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it + #[arg(long)] + pub(crate) tiff_layers: bool, + /// Compress TIFF output: none (the default), lzw or zip + #[arg(long, value_name = "none|lzw|zip")] + pub(crate) tiff_compression: Option, + /// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its + /// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24 + #[arg(long, value_name = "24|32")] + pub(crate) tga_bits: Option, + /// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16 + #[arg(long, value_name = "8|10|12|16")] + pub(crate) dpx_bits: Option, + /// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or + /// off), bc2 (DXT3) or bc3 (DXT5) + #[arg(long, value_name = "none|bc1|bc2|bc3")] + pub(crate) dds_compression: Option, + /// Also write a DDS texture's full mip chain + #[arg(long)] + pub(crate) dds_mipmaps: bool, + /// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman + /// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray + /// and CMYK shrink) or progressive + #[arg(long, value_name = "baseline|optimized|progressive")] + pub(crate) jpeg_format: Option, } -/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`). -pub(crate) fn parse(sub: &Subcommand, args: &[String]) -> Result, UsageError> { - let mut a = Args { positional: Vec::new(), flags: Vec::new() }; - let unknown = |flag: &str| UsageError::UnknownFlag { flag: flag.to_owned(), sub: sub.name }; - let mut i = 0; - while let Some(s) = args.get(i) { - if s == "--help" || s == "-h" { - return Ok(None); +/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) +/// +/// An that is the file is refused, as the result would replace the original; +/// --in-place allows it. +#[derive(Args)] +pub(crate) struct ConvertArgs { + /// The file to read + #[arg(value_name = "IN")] + pub(crate) input: String, + /// The file to write; its extension picks the format unless --format is given + #[arg(value_name = "OUT")] + pub(crate) output: String, + /// Allow an that is the file, replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, +} + +/// Print the document as JSON (size, mode, depth, layer tree) +#[derive(Args)] +pub(crate) struct InfoArgs { + pub(crate) file: String, + /// One line of JSON instead of pretty-printed + #[arg(long)] + pub(crate) compact: bool, +} + +/// Open a file (or start a new document), run engine commands in order, save the result +/// +/// Prints each command's JSON result. Give exactly one of or --new. An --out that is the +/// is refused, as the result would replace the original; --in-place allows it. +#[derive(Args)] +pub(crate) struct RunArgs { + /// The document to open + pub(crate) file: Option, + /// Start from a new document with these JSON parameters instead of opening a file + #[arg(long, value_name = "JSON")] + pub(crate) new: Option, + /// An engine command to run; repeat for several, run in order + #[arg(long, value_name = "ID", action = ArgAction::Append)] + pub(crate) cmd: Vec, + /// JSON parameters for the --cmd before it (default {}) + #[arg(long, value_name = "JSON", action = ArgAction::Append)] + pub(crate) params: Vec, + /// Save the result to this file + #[arg(long, value_name = "FILE")] + pub(crate) out: Option, + /// Allow an --out that is the , replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, + /// `--cmd` and `--params` values in command-line order (`true` for a `--cmd`), set by [`parse`]. + #[arg(skip)] + pub(crate) steps: Vec<(bool, String)>, +} + +/// Apply an action list to every image in a directory +/// +/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps +/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded +/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a +/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]}) +/// plays the action --action names (needed when the set holds more than one) with the whole set +/// loaded, so its steps can play other actions of the set. +/// +/// An --out folder that is the --in folder is refused, as the results would replace the +/// originals; --in-place allows it. +#[derive(Args)] +pub(crate) struct BatchArgs { + /// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn) + #[arg(long, value_name = "FILE")] + pub(crate) actions: Option, + /// Which action of an Actions set to play + #[arg(long, value_name = "NAME")] + pub(crate) action: Option, + /// The folder of images to process + #[arg(long = "in", value_name = "DIR")] + pub(crate) in_dir: Option, + /// The folder to write the results to, as . + #[arg(long, value_name = "DIR")] + pub(crate) out: Option, + /// Allow an --out that is the --in folder, replacing the originals + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, +} + +/// Run a droplet (File › Automate › Create Droplet) on images and folders +#[derive(Args)] +pub(crate) struct DropletArgs { + /// The droplet to run + #[arg(value_name = "FILE.pcdroplet")] + pub(crate) file: String, + /// Images and folders to run it on + #[arg(value_name = "FILE-OR-DIR", required = true)] + pub(crate) inputs: Vec, + /// The folder to write the results to + #[arg(long, value_name = "DIR")] + pub(crate) out: Option, +} + +/// List the engine command registry +#[derive(Args)] +pub(crate) struct CommandsArgs { + /// Print the entries as JSON + #[arg(long)] + pub(crate) json: bool, + /// Only the commands matching this text + #[arg(long, value_name = "TEXT", allow_negative_numbers = true)] + pub(crate) filter: Option, +} + +// The control token and the folders automation may touch: shared by `mcp` and `serve`. +#[derive(Args)] +pub(crate) struct ControlArgs { + /// The control token (64 hex digits) + #[arg(long, value_name = "64-HEX", value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] + pub(crate) control_token: Option, + /// A file holding the control token (a server creates it when missing) + #[arg(long, value_name = "PATH", value_parser = NonEmptyStringValueParser::new())] + pub(crate) control_token_file: Option, + /// The folder automation may read from (none without it) + #[arg(long, value_name = "DIR", value_parser = NonEmptyStringValueParser::new())] + pub(crate) automation_read_root: Option, + /// The folder automation may write to (none without it) + #[arg(long, value_name = "DIR", value_parser = NonEmptyStringValueParser::new())] + pub(crate) automation_write_root: Option, +} + +/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `) +#[derive(Args)] +pub(crate) struct McpArgs { + /// Bridge to the PhotoCraft app listening here instead of a headless engine + #[arg(long, value_name = "127.0.0.1:PORT", value_parser = NonEmptyStringValueParser::new())] + pub(crate) bridge: Option, + #[command(flatten)] + pub(crate) control: ControlArgs, +} + +/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1: +/// +/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute, +/// jobs.list/cancel, engine.commands, +/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, +/// session.list, batch, methods (docs/control-protocol.md#headless-server). +#[derive(Args)] +pub(crate) struct ServeArgs { + /// Listen on 127.0.0.1: instead of stdio + #[arg(long, value_name = "PORT", value_parser = NonEmptyStringValueParser::new())] + pub(crate) port: Option, + #[command(flatten)] + pub(crate) control: ControlArgs, +} + +/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart, +/// their indices say how they interleave. +fn steps(m: &ArgMatches) -> Vec<(bool, String)> { + let mut steps: Vec<(usize, bool, String)> = Vec::new(); + for (id, is_cmd) in [("cmd", true), ("params", false)] { + if let (Some(at), Some(values)) = (m.indices_of(id), m.get_many::(id)) { + steps.extend(at.zip(values).map(|(i, v)| (i, is_cmd, v.clone()))); } - if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { - if sub.bare.contains(&k) { - return Err(UsageError::TakesNoValue(k.to_owned())); - } - if !sub.values.contains(&k) { - return Err(unknown(k)); - } - a.flags.push((k.to_owned(), Some(v.to_owned()))); - } else if sub.values.contains(&s.as_str()) { - // A flag where the value should be is a missing value, not the value; help still wins. - let v = args.get(i + 1); - if v.is_some_and(|v| v == "--help" || v == "-h") { - return Ok(None); - } - let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| UsageError::NeedsValue(s.clone()))?; - a.flags.push((s.clone(), Some(v.clone()))); - i += 1; - } else if sub.bare.contains(&s.as_str()) { - a.flags.push((s.clone(), None)); - } else if s.starts_with("--") { - return Err(unknown(s)); - } else { - a.positional.push(s.clone()); - } - i += 1; } - validate_server_args(sub.name, &a)?; - Ok(Some(a)) + steps.sort_by_key(|s| s.0); + steps.into_iter().map(|(_, is_cmd, v)| (is_cmd, v)).collect() } -/// Refuse launch options that would otherwise be silently ignored. In particular, bridge -/// roots belong to the desktop process: accepting them here promises authority we cannot set. -fn validate_server_args(sub: &'static str, a: &Args) -> Result<(), UsageError> { - if !matches!(sub, "mcp" | "serve") { - return Ok(()); - } - if !a.positional.is_empty() { - return Err(UsageError::Positional(sub)); +/// Parse the arguments (without the binary name). Help where a flag's value should be +/// (`--out --help`) is still help: clap reports the missing value first. +pub(crate) fn parse(args: &[String]) -> Result { + let mut cmd = Cli::command(); + let given: Vec<&str> = std::iter::once("photocraft-cli").chain(args.iter().map(String::as_str)).collect(); + let matches = match cmd.try_get_matches_from_mut(given.iter().copied()) { + Err(e) if e.kind() == ErrorKind::InvalidValue && given.iter().take_while(|a| **a != "--").any(|a| matches!(*a, "--help" | "-h")) => { + let help = ["photocraft-cli", given.get(1).copied().unwrap_or_default(), "--help"]; + return Err(Cli::command().try_get_matches_from(help).err().unwrap_or(e)); + } + matches => matches?, + }; + let mut cli = Cli::from_arg_matches(&matches)?; + match (&mut cli.command, matches.subcommand()) { + (Command::Run(r), Some((_, m))) => r.steps = steps(m), + (Command::Mcp(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), &given, m, "bridge")?, + (Command::Serve(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), &given, m, "port")?, + _ => {} } + Ok(cli) +} + +/// Refuse `mcp` and `serve` launch options that would otherwise be silently ignored (#8035): a +/// repeated one (the last wins everywhere else), a token without the bridge or port that reads it, +/// and bridge roots, which belong to the desktop process: accepting them here promises authority +/// the bridge cannot set. +fn check_server(sub: Option<&mut clap::Command>, given: &[&str], m: &ArgMatches, transport: &str) -> Result<(), clap::Error> { + let Some(sub) = sub else { return Ok(()) }; let mut seen = std::collections::HashSet::new(); - for (flag, value) in &a.flags { - if !seen.insert(flag) { - return Err(UsageError::Repeated(flag.clone())); - } - if value.as_deref().is_none_or(str::is_empty) { - return Err(UsageError::EmptyValue(flag.clone())); + let flags = given.iter().skip(1).take_while(|a| **a != "--").filter(|a| a.starts_with("--")); + if let Some(flag) = flags.map(|a| a.split_once('=').map_or(*a, |(k, _)| k)).find(|k| !seen.insert(*k)) { + return Err(sub.error(ErrorKind::ArgumentConflict, format!("the argument '{flag}' cannot be used multiple times"))); + } + for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] { + if m.contains_id(id) && !m.contains_id(transport) { + return Err(sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires --{transport}"))); } } - if a.has("--control-token") && a.has("--control-token-file") { - return Err(UsageError::TokenAndTokenFile); - } - let transport = if sub == "mcp" { "--bridge" } else { "--port" }; - for flag in ["--control-token", "--control-token-file"] { - if a.has(flag) && !a.has(transport) { - return Err(UsageError::Requires { flag, transport }); - } - } - if sub == "mcp" && a.has("--bridge") && (a.has("--automation-read-root") || a.has("--automation-write-root")) { - return Err(UsageError::BridgeRoots); + if transport == "bridge" && m.contains_id(transport) && (m.contains_id("automation_read_root") || m.contains_id("automation_write_root")) { + let message = "configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode"; + return Err(sub.error(ErrorKind::ArgumentConflict, message)); } Ok(()) } - -impl Args { - pub(crate) fn get(&self, k: &str) -> Option<&str> { - self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref()) - } - pub(crate) fn has(&self, k: &str) -> bool { - self.flags.iter().any(|(f, _)| f == k) - } -} diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index 5c0c94430a..09a83680ef 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -7,18 +7,18 @@ use photocraft_automation::{AuthorizedWorkspace, AutomationError, Headless, Phot use crate::{ CliError, Result, - args::Args, + args::{CommandsArgs, ControlArgs, McpArgs, ServeArgs}, output::{JsonStyle, print_json}, }; -pub(crate) fn automation_workspace(args: &Args) -> Result { - AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new)) +pub(crate) fn automation_workspace(args: &ControlArgs) -> Result { + AuthorizedWorkspace::new(args.automation_read_root.as_deref().map(Path::new), args.automation_write_root.as_deref().map(Path::new)) } -pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> Result { +pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { let h = Headless::new(); let list = h.command_list(); - let needle = a.get("--filter").map(str::to_lowercase); + let needle = a.filter.as_deref().map(str::to_lowercase); let items: Vec<&Value> = list .as_array() .map(|v| v.iter().collect::>()) @@ -26,7 +26,7 @@ pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> Result { .into_iter() .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) .collect(); - if a.has("--json") { + if a.json { return print_json(out, &Value::Array(items.into_iter().cloned().collect()), JsonStyle::Pretty); } for c in items { @@ -36,14 +36,14 @@ pub(crate) fn commands(a: &Args, out: &mut dyn Write) -> Result { Ok(()) } -pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> Result { +pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { use std::sync::{Arc, Mutex}; - let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?))); - match a.get("--port") { + let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(&a.control)?))); + match &a.port { Some(port) => { let port: u16 = port.parse().map_err(|_| CliError::BadPort(port.to_owned()))?; let addr = format!("127.0.0.1:{port}"); - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); + let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.as_deref().map(PathBuf::from)); let token = security::server_token(supplied.as_deref(), token_file.as_deref())?; if let Some(path) = &token_file { let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); @@ -62,14 +62,14 @@ pub(crate) fn serve(a: &Args, err: &mut dyn Write) -> Result { } } -pub(crate) fn mcp(a: &Args) -> Result { - let server = match a.get("--bridge") { +pub(crate) fn mcp(a: &McpArgs) -> Result { + let server = match &a.bridge { Some(addr) => { - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); + let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.as_deref().map(PathBuf::from)); let token = security::client_token(supplied.as_deref(), token_file.as_deref())?; PhotocraftMcp::bridge(addr, &token)? } - None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?), + None => PhotocraftMcp::headless_with_workspace(automation_workspace(&a.control)?), }; let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; Ok(rt.block_on(server.serve_stdio())?) diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index 2b14e04889..4e44c549b7 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::{ CliError, Result, actions::load_actions_file, - args::Args, + args::{BatchArgs, DropletArgs}, output::{export_opts, warn_all}, }; @@ -46,18 +46,18 @@ fn strip_shell_steps(action: &mut Action) { (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); } -pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { +pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { let needs = |what| CliError::Needs { sub: "batch", what }; - let actions_path = a.get("--actions").ok_or_else(|| needs("--actions "))?; - let in_dir = PathBuf::from(a.get("--in").ok_or_else(|| needs("--in "))?); - let out_dir = PathBuf::from(a.get("--out").ok_or_else(|| needs("--out "))?); - let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + let actions_path = a.actions.as_deref().ok_or_else(|| needs("--actions "))?; + let in_dir = PathBuf::from(a.in_dir.as_deref().ok_or_else(|| needs("--in "))?); + let out_dir = PathBuf::from(a.out.as_deref().ok_or_else(|| needs("--out "))?); + let (actions, mut set, warnings) = load_actions_file(actions_path, a.action.as_deref())?; set.iter_mut().for_each(strip_shell_steps); warn_all(err, &warnings); - let opts = export_opts(a)?; + let opts = export_opts(&a.export)?; // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). - if !a.has("--in-place") && same_dir(&in_dir, &out_dir) { + if !a.in_place && same_dir(&in_dir, &out_dir) { return Err(CliError::OutIsIn(out_dir)); } std::fs::create_dir_all(&out_dir)?; @@ -71,7 +71,7 @@ pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Resul let (mut ok, mut failed) = (0, 0); let mut written = photocraft_engine::file_cmds::OutputClaims::default(); // `--format .jpg` names outputs `.jpg`, as `--format jpg` does (#490). - let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f)); + let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f)); for input in &inputs { let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into()); let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); @@ -114,14 +114,10 @@ pub(crate) fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Resul if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) } } -pub(crate) fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { - let needs = |what| CliError::Needs { sub: "droplet", what }; - let (file, inputs) = a.positional.split_first().ok_or_else(|| needs(" and inputs"))?; - if inputs.is_empty() { - return Err(needs("at least one input file or folder")); - } +pub(crate) fn droplet(a: &DropletArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let (file, inputs) = (&a.file, &a.inputs); let mut p = json!({"droplet": file, "input": inputs}); - if let Some(o) = a.get("--out") { + if let Some(o) = &a.out { p["output"] = json!(o); } let mut s = photocraft_engine::Session::new(); diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index b453127e13..48051a09b8 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -8,7 +8,7 @@ use photocraft_engine::type_extra_cmds::missing_font_warnings; use crate::{ CliError, Result, - args::Args, + args::{ConvertArgs, InfoArgs, RunArgs}, output::{JsonStyle, export_opts, print_json, warn_all}, }; @@ -18,33 +18,29 @@ use crate::{ /// folder (#7437). The paths are compared when the command starts and the save opens `output` /// again later, so another process swapping in a link meanwhile goes unnoticed: this guards /// against mistakes, it is not a security boundary. -fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> Result { - if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { +fn refuse_overwriting_input(in_place: bool, input: &str, output: &str) -> Result { + if !in_place && photocraft_engine::file_cmds::same_file(input, output) { return Err(CliError::OutIsInput(output.to_owned())); } Ok(()) } -pub(crate) fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> Result { - let [input, output] = a.positional.as_slice() else { - return Err(CliError::Needs { sub: "convert", what: " " }); - }; - let opts = export_opts(a)?; - refuse_overwriting_input(a, input, output)?; +pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result { + let (input, output) = (&a.input, &a.output); + let opts = export_opts(&a.export)?; + refuse_overwriting_input(a.in_place, input, output)?; let mut o = files::open(Path::new(input))?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); warn_all(err, &missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None)?; + let ws = files::save(&o.document, Path::new(output), a.export.format.as_deref(), &opts, None)?; warn_all(err, &ws); Ok(()) } -pub(crate) fn info(a: &Args, out: &mut dyn Write) -> Result { - let [file] = a.positional.as_slice() else { - return Err(CliError::Needs { sub: "info", what: "" }); - }; +pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { + let file = &a.file; let mut h = Headless::trusted_local(); let opened = h.open(Path::new(file))?; let mut doc = h.inspect(None)?; @@ -60,50 +56,48 @@ pub(crate) fn info(a: &Args, out: &mut dyn Write) -> Result { m.remove(k); } } - print_json(out, &doc, if a.has("--compact") { JsonStyle::Compact } else { JsonStyle::Pretty }) + print_json(out, &doc, if a.compact { JsonStyle::Compact } else { JsonStyle::Pretty }) } /// `(id, params)` pairs from repeated `--cmd`/`--params` flags. -fn command_list(a: &Args) -> Result> { +fn command_list(a: &RunArgs) -> Result> { let mut cmds: Vec<(String, Value)> = Vec::new(); - for (k, v) in &a.flags { - match (k.as_str(), v) { - ("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))), - ("--params", Some(p)) => { - let last = cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?; - last.1 = serde_json::from_str(p).map_err(|source| CliError::BadParams { id: last.0.clone(), source })?; - } - _ => {} + for (is_cmd, v) in &a.steps { + if *is_cmd { + cmds.push((v.clone(), json!({}))); + } else { + let last = cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?; + last.1 = serde_json::from_str(v).map_err(|source| CliError::BadParams { id: last.0.clone(), source })?; } } Ok(cmds) } -pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Result { - let opts = export_opts(a)?; +pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let opts = export_opts(&a.export)?; let mut h = Headless::trusted_local(); // Opening, running commands and exporting can all notice the same missing font: // one `run` invocation reports each warning line at most once. let mut reported_fonts = std::collections::HashSet::::new(); - match (a.positional.as_slice(), a.get("--new")) { - ([file], None) => { + match (a.file.as_deref(), a.new.as_deref()) { + (Some(file), None) => { // Refused before the file is read or any command runs. - if let Some(out) = a.get("--out") { - refuse_overwriting_input(a, file, out)?; + if let Some(out) = &a.out { + refuse_overwriting_input(a.in_place, file, out)?; } let o = h.open(Path::new(file))?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); reported_fonts.extend(ws); } - ([], Some(new)) => { + (None, Some(new)) => { let p: Value = serde_json::from_str(new).map_err(CliError::BadNew)?; h.command_run("file.new", p).map_err(CliError::New)?; } _ => return Err(CliError::Needs { sub: "run", what: "exactly one of or --new " }), } let cmds = command_list(a)?; - if cmds.is_empty() && a.get("--out").is_none() { + if cmds.is_empty() && a.out.is_none() { return Err(CliError::Needs { sub: "run", what: "at least one --cmd (or --out)" }); } for (id, params) in cmds { @@ -124,8 +118,8 @@ pub(crate) fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> Re let remaining: Vec = h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &remaining); - if let Some(o) = a.get("--out") { - let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts)?; + if let Some(o) = &a.out { + let r = h.save(None, Some(Path::new(o)), a.export.format.as_deref(), &opts)?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 276a924ca5..277ff706f6 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -14,8 +14,10 @@ mod error; mod output; pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions}; -pub use args::USAGE; -use args::{SUBCOMMANDS, parse}; +use args::{Command, parse}; +use automation::{commands, mcp, serve}; +use batch::{batch, droplet}; +use document::{convert, info, run_cmds}; pub(crate) use error::{CliError, Result}; /// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error @@ -27,7 +29,8 @@ pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Wr match a.clone().into_string() { Ok(s) => strings.push(s), Err(raw) => { - let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy()); + let usage = ::command().render_usage(); + let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{usage}", i + 1, raw.to_string_lossy()); return 2; } } @@ -37,38 +40,31 @@ pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Wr /// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage). pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { - let Some(cmd) = args.first() else { - let _ = write!(err, "{USAGE}"); - return 2; - }; - match cmd.as_str() { - "-h" | "--help" | "help" => { - let _ = write!(out, "{USAGE}"); - return 0; - } - "--version" | "version" => { - let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version()); - return 0; - } - _ => {} + if matches!(args.first().map(String::as_str), Some("--version" | "version")) { + let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version()); + return 0; } - let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else { - let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}"); - return 2; - }; - let parsed = match parse(sub, &args[1..]) { - Ok(Some(p)) => p, - // ` --help`: usage, without reading inputs or starting a server. - Ok(None) => { - let _ = write!(out, "{USAGE}"); - return 0; - } + // Help (`help`, `-h`, ` --help`) comes back as an error too, for `out`, exit 0: the + // subcommand never runs. + let cli = match parse(args) { + Ok(cli) => cli, Err(e) => { - let _ = writeln!(err, "error: {e}\n\n{USAGE}"); - return 2; + let to: &mut dyn Write = if e.use_stderr() { err } else { out }; + let _ = write!(to, "{}", e.render()); + return e.exit_code(); } }; - match (sub.run)(&parsed, out, err) { + let result = match &cli.command { + Command::Convert(a) => convert(a, out, err), + Command::Info(a) => info(a, out), + Command::Run(a) => run_cmds(a, out, err), + Command::Batch(a) => batch(a, out, err), + Command::Droplet(a) => droplet(a, out, err), + Command::Commands(a) => commands(a, out), + Command::Mcp(a) => mcp(a), + Command::Serve(a) => serve(a, err), + }; + match result { Ok(()) => 0, Err(e) => { let _ = writeln!(err, "error: {e}"); diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index f106e92c13..08797bde37 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -2,40 +2,40 @@ use photocraft_io::ExportOptions; use serde_json::Value; use std::io::Write; -use crate::{CliError, Result, args::Args}; +use crate::{CliError, Result, args::ExportArgs}; -pub(crate) fn export_opts(a: &Args) -> Result { +pub(crate) fn export_opts(a: &ExportArgs) -> Result { let bad = |flag, value: &str, expected| CliError::BadValue { flag, value: value.to_owned(), expected }; // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() }; - if let Some(q) = a.get("--quality") { + let mut o = ExportOptions { tiff_layers: a.tiff_layers, ..Default::default() }; + if let Some(q) = &a.quality { let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| bad("--quality", q, "a whole number from 1 to 100"))?; o.encode.jpeg_quality = q; // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. o.encode.webp_quality = q; o.encode.webp_lossless = false; } - if let Some(b) = a.get("--tga-bits") { + if let Some(b) = &a.tga_bits { o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| bad("--tga-bits", b, "24 or 32"))?); } - if let Some(b) = a.get("--dpx-bits") { + if let Some(b) = &a.dpx_bits { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| bad("--dpx-bits", b, "8, 10, 12 or 16"))?); } - if let Some(c) = a.get("--tiff-compression") { - o.encode.tiff_compression = match c { + if let Some(c) = &a.tiff_compression { + o.encode.tiff_compression = match c.as_str() { "none" => photocraft_codecs::TiffCompression::None, "lzw" => photocraft_codecs::TiffCompression::Lzw, "zip" => photocraft_codecs::TiffCompression::Deflate, _ => return Err(bad("--tiff-compression", c, "none, lzw or zip")), }; } - if let Some(f) = a.get("--jpeg-format") { + if let Some(f) = &a.jpeg_format { o.encode.jpeg_format = photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| bad("--jpeg-format", f, "baseline, optimized or progressive"))?; } - if let Some(c) = a.get("--dds-compression") { + if let Some(c) = &a.dds_compression { o.encode.dds.compression = photocraft_io::DdsCompression::from_name(c).ok_or_else(|| bad("--dds-compression", c, "none, bc1, bc2 or bc3"))?; } - o.encode.dds.mipmaps = a.has("--dds-mipmaps"); + o.encode.dds.mipmaps = a.dds_mipmaps; Ok(o) } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 7b4402de12..bef9241582 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -40,7 +40,7 @@ fn ok(cmd: &mut Command) -> Printed { fn usage_and_unknown_command() { let o = bin().output().unwrap(); assert_eq!(o.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE")); + assert!(String::from_utf8_lossy(&o.stderr).contains("convert")); let o = bin().arg("frobnicate").output().unwrap(); assert_eq!(o.status.code(), Some(2)); let out = ok(bin().arg("--help")).stdout; @@ -59,14 +59,14 @@ fn every_subcommand_answers_help() { let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap(); let out = String::from_utf8_lossy(&o.stdout); assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr)); - assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}"); + assert!(out.contains(&format!("photocraft-cli {sub}")), "{sub} {help}: {out}"); } } // Help where a flag's value should be is still help, not a missing value or a file named `-h`. for args in [&["run", "x.png", "--out", "--help"][..], &["commands", "--filter", "-h"], &["mcp", "--bridge", "-h"], &["serve", "--port", "--help"]] { let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap(); assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr)); - assert!(String::from_utf8_lossy(&o.stdout).contains("USAGE"), "{args:?}"); + assert!(String::from_utf8_lossy(&o.stdout).contains(&format!("photocraft-cli {}", args[0])), "{args:?}"); } assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing"); } @@ -85,7 +85,7 @@ fn unknown_flags_are_usage_errors() { let o = cmd.output().unwrap(); let err = String::from_utf8_lossy(&o.stderr); assert_eq!(o.status.code(), Some(2), "{err}"); - assert!(err.contains(&format!("unknown flag {flag}")), "{err}"); + assert!(err.contains(flag), "{err}"); }; for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] { fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat"); @@ -98,7 +98,7 @@ fn unknown_flags_are_usage_errors() { fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json"); let o = bin().args(["commands", "--json=yes"]).output().unwrap(); assert_eq!(o.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value")); + assert!(String::from_utf8_lossy(&o.stderr).contains("--json")); // A flag can't be a value: a missing value is reported, not filled with the next flag. let o = bin() .args(["batch", "--actions"]) @@ -111,7 +111,7 @@ fn unknown_flags_are_usage_errors() { .output() .unwrap(); assert_eq!(o.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&o.stderr).contains("--format needs a value")); + assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--format")); assert!(!out_dir.exists(), "nothing written"); // `--flag=value` still passes a value that starts with `--`. ok(bin().args(["commands", "--filter=--json"])); @@ -144,23 +144,23 @@ fn server_usage_error(args: &[&str], expected: &str) { #[test] fn server_modes_reject_ignored_or_conflicting_options() { for sub in ["mcp", "serve"] { - server_usage_error(&[sub, "stray.psd"], "does not take positional arguments"); + server_usage_error(&[sub, "stray.psd"], "unexpected argument 'stray.psd'"); for flag in ["--control-token", "--control-token-file"] { server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" }); } for flag in ["--automation-read-root", "--automation-write-root"] { - server_usage_error(&[sub, flag, ".", flag, "."], "may only be specified once"); - server_usage_error(&[sub, &format!("{flag}=")], "needs a non-empty value"); - server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("{flag} needs a value")); + server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times"); + server_usage_error(&[sub, &format!("{flag}=")], &format!("'{flag} ")); + server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} ")); } let transport = if sub == "mcp" { "--bridge" } else { "--port" }; for flag in [transport, "--control-token", "--control-token-file"] { - server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "may only be specified once"); - server_usage_error(&[sub, flag, ""], "needs a non-empty value"); + server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "cannot be used multiple times"); + server_usage_error(&[sub, flag, ""], &format!("'{flag} ")); } server_usage_error( &[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], - "use either --control-token or --control-token-file", + "cannot be used with", ); } for flag in ["--automation-read-root", "--automation-write-root"] { @@ -231,7 +231,7 @@ fn convert_errors() { let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap(); assert_eq!(o.status.code(), Some(1)); let o = bin().args(["convert", "only-one"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); + assert_eq!(o.status.code(), Some(2)); std::fs::remove_dir_all(d).unwrap(); } From 3765fcba15d52f2192d95888743c67fb9f8ec32c Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:05:40 +0300 Subject: [PATCH 16/40] docs(cli): note help-subcommand exit codes and clarify mcp/serve and batch help (#7071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour change from the clap port, not listed in its commit: `help` followed by an unknown name or extra arguments is a usage error (exit 2); before, `help …` always printed the whole USAGE with exit 0. usage_and_unknown_command now pins `help frobnicate` exiting 2 with the error on stderr. The --control-token-file help says it is used instead of --control-token, as the old USAGE showed them as either/or. The --in-place help carries the "--out equal to --in is refused" sentence, so `batch -h` shows it and not only `batch --help`. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/args.rs | 6 ++++-- apps/photocraft-cli/tests/it/cli.rs | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index e5503e35f5..13487c5a2c 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -147,7 +147,8 @@ pub(crate) struct BatchArgs { /// The folder to write the results to, as . #[arg(long, value_name = "DIR")] pub(crate) out: Option, - /// Allow an --out that is the --in folder, replacing the originals + /// An --out folder that is the --in folder is refused, as the results would replace the + /// originals; --in-place allows it #[arg(long)] pub(crate) in_place: bool, #[command(flatten)] @@ -185,7 +186,8 @@ pub(crate) struct ControlArgs { /// The control token (64 hex digits) #[arg(long, value_name = "64-HEX", value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] pub(crate) control_token: Option, - /// A file holding the control token (a server creates it when missing) + /// A file holding the control token, used instead of --control-token (a server creates it + /// when missing) #[arg(long, value_name = "PATH", value_parser = NonEmptyStringValueParser::new())] pub(crate) control_token_file: Option, /// The folder automation may read from (none without it) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index bef9241582..f46f58a285 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -43,6 +43,10 @@ fn usage_and_unknown_command() { assert!(String::from_utf8_lossy(&o.stderr).contains("convert")); let o = bin().arg("frobnicate").output().unwrap(); assert_eq!(o.status.code(), Some(2)); + // `help` names a subcommand or nothing: anything else is a usage error too. + let o = bin().args(["help", "frobnicate"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(!o.stderr.is_empty()); let out = ok(bin().arg("--help")).stdout; assert!(out.contains("convert")); let out = ok(bin().arg("--version")).stdout; From 1d6d1da0a9e7a956aaa703e67fc31df56fff76cc Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:13:32 +0300 Subject: [PATCH 17/40] refactor(cli): drop duplicate --in-place help and a panicking matches lookup (#7071) batch --help printed the "--out that is the --in folder is refused" sentence twice, in the subcommand's long help and on --in-place. Keep it on the flag, where -h shows it too. steps() read --cmd/--params with get_many, which panics on a type mismatch, against the crate's never-panic rule. Use try_get_many and treat an error as no values; behaviour is otherwise the same. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/args.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 13487c5a2c..d15327e03e 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -130,9 +130,6 @@ pub(crate) struct RunArgs { /// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]}) /// plays the action --action names (needed when the set holds more than one) with the whole set /// loaded, so its steps can play other actions of the set. -/// -/// An --out folder that is the --in folder is refused, as the results would replace the -/// originals; --in-place allows it. #[derive(Args)] pub(crate) struct BatchArgs { /// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn) @@ -228,7 +225,7 @@ pub(crate) struct ServeArgs { fn steps(m: &ArgMatches) -> Vec<(bool, String)> { let mut steps: Vec<(usize, bool, String)> = Vec::new(); for (id, is_cmd) in [("cmd", true), ("params", false)] { - if let (Some(at), Some(values)) = (m.indices_of(id), m.get_many::(id)) { + if let (Some(at), Some(values)) = (m.indices_of(id), m.try_get_many::(id).ok().flatten()) { steps.extend(at.zip(values).map(|(i, v)| (i, is_cmd, v.clone()))); } } From da11e381ba26af34f386873f49fa6bb3b60af53c Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 15:13:28 +0300 Subject: [PATCH 18/40] refactor(cli): reject repeated flags, use clap's --version (#7071) The behaviour changes decided in step 0 of the issue: - A repeated single-value flag is a usage error (exit 2) instead of the last one winning: args_override_self is gone, and with it the argument scan that found repeats for mcp and serve (#8035). --cmd and --params stay repeatable. - A value starting with '-' was already written --out=-image.png or ./-image.png since the clap port (a flag can't be a value since #8035); this pins it, and that `--filter -1` still works and `--quality -1` is still refused by its range. - The bare `version` subcommand is gone; clap's version flag prints the same `photocraft-cli ` line for --version and now -V too. `help ` stays. clap's version wants a &'static str, so build_info gains long_version_static, long_version worked out once in a OnceLock, next to long_version. The desktop app will need the same in step 5. flag_value_forms_and_repeats and top_level_help_version_and_no_arguments now pin the new behaviour; a_value_starting_with_a_dash_is_written_with_equals is new. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/args.rs | 25 ++++++--------- apps/photocraft-cli/src/lib.rs | 8 ++--- apps/photocraft-cli/tests/it/cli.rs | 49 +++++++++++++++++++++++------ crates/engine/src/build_info.rs | 7 +++++ 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index d15327e03e..9b4f67c84e 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -1,8 +1,9 @@ //! The command line, as clap types. Values stay strings: the handlers check them, with their own //! messages (#7071). A flag a subcommand doesn't take is a usage error, so a typo such as -//! `--fromat` can't be silently ignored (#423). A value flag never takes the next flag as its -//! value: a value starting with `-` is written `--out=-image.png` (or `./-image.png`); negative -//! numbers are kept where they mean something. +//! `--fromat` can't be silently ignored (#423), and so is a repeated one: `--quality 80 --quality +//! 60` is more likely a mistake than a choice. A value starting with `-` is written +//! `--out=-image.png` (or `./-image.png`), so a value flag never takes the next flag as its value; +//! negative numbers are kept where they mean something. use clap::builder::NonEmptyStringValueParser; use clap::error::ErrorKind; @@ -10,7 +11,7 @@ use clap::{ArgAction, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, /// photocraft-cli: headless PhotoCraft #[derive(Parser)] -#[command(name = "photocraft-cli", arg_required_else_help = true, args_override_self = true)] +#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)] pub(crate) struct Cli { #[command(subcommand)] pub(crate) command: Command, @@ -248,24 +249,18 @@ pub(crate) fn parse(args: &[String]) -> Result { let mut cli = Cli::from_arg_matches(&matches)?; match (&mut cli.command, matches.subcommand()) { (Command::Run(r), Some((_, m))) => r.steps = steps(m), - (Command::Mcp(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), &given, m, "bridge")?, - (Command::Serve(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), &given, m, "port")?, + (Command::Mcp(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), m, "bridge")?, + (Command::Serve(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), m, "port")?, _ => {} } Ok(cli) } /// Refuse `mcp` and `serve` launch options that would otherwise be silently ignored (#8035): a -/// repeated one (the last wins everywhere else), a token without the bridge or port that reads it, -/// and bridge roots, which belong to the desktop process: accepting them here promises authority -/// the bridge cannot set. -fn check_server(sub: Option<&mut clap::Command>, given: &[&str], m: &ArgMatches, transport: &str) -> Result<(), clap::Error> { +/// token without the bridge or port that reads it, and bridge roots, which belong to the desktop +/// process: accepting them here promises authority the bridge cannot set. +fn check_server(sub: Option<&mut clap::Command>, m: &ArgMatches, transport: &str) -> Result<(), clap::Error> { let Some(sub) = sub else { return Ok(()) }; - let mut seen = std::collections::HashSet::new(); - let flags = given.iter().skip(1).take_while(|a| **a != "--").filter(|a| a.starts_with("--")); - if let Some(flag) = flags.map(|a| a.split_once('=').map_or(*a, |(k, _)| k)).find(|k| !seen.insert(*k)) { - return Err(sub.error(ErrorKind::ArgumentConflict, format!("the argument '{flag}' cannot be used multiple times"))); - } for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] { if m.contains_id(id) && !m.contains_id(transport) { return Err(sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires --{transport}"))); diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 277ff706f6..1ddbd4efbe 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -40,12 +40,8 @@ pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Wr /// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage). pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { - if matches!(args.first().map(String::as_str), Some("--version" | "version")) { - let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version()); - return 0; - } - // Help (`help`, `-h`, ` --help`) comes back as an error too, for `out`, exit 0: the - // subcommand never runs. + // Help and `--version` (`help`, `-h`, ` --help`, `-V`) come back as errors too, + // for `out`, exit 0: the subcommand never runs. let cli = match parse(args) { Ok(cli) => cli, Err(e) => { diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index f46f58a285..81604f2568 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1025,7 +1025,8 @@ fn invoke(args: &[&str]) -> Invoke { const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; -/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr. +/// Pins top-level dispatch: help and the version go to stdout (exit 0), no arguments is a usage +/// error on stderr. There is no `version` subcommand. #[test] fn top_level_help_version_and_no_arguments() { let Invoke { code, stdout: out, stderr: err } = invoke(&[]); @@ -1034,11 +1035,14 @@ fn top_level_help_version_and_no_arguments() { let Invoke { code, stdout: out, stderr: err } = invoke(&[help]); assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}"); } - for version in ["--version", "version"] { + for version in ["--version", "-V"] { let Invoke { code, stdout: out, stderr: err } = invoke(&[version]); assert_eq!((code, err.as_str()), (0, ""), "{version}"); - assert!(out.starts_with("photocraft-cli "), "{version}: {out}"); + assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}"); } + let Invoke { code, stdout: out, stderr: err } = invoke(&["version"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("version"), "{err}"); let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]); assert_eq!((code, out.is_empty()), (2, true)); assert!(err.contains("frobnicate"), "{err}"); @@ -1071,7 +1075,8 @@ fn every_subcommand_rejects_an_unknown_flag() { } } -/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags. +/// `--flag=value`, a bare flag given a value, a value flag without one, repeated flags, and values +/// that start with `-`. #[test] fn flag_value_forms_and_repeats() { let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::>(); @@ -1080,13 +1085,25 @@ fn flag_value_forms_and_repeats() { let spaced = invoke(&["commands", "--filter", "file.new"]).stdout; assert_eq!(eq, spaced); assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}"); - // The last of a repeated value flag wins, whichever order they come in. - let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]); + // A repeated flag is a usage error naming it, more likely a mistake than a choice. + for repeat in [&["commands", "--filter", "zzz-no-match", "--filter", "file.new"][..], &["commands", "--json", "--json"][..]] { + let Invoke { code, stdout: out, stderr: err } = invoke(repeat); + assert_eq!((code, out.is_empty()), (2, true), "{repeat:?}"); + assert!(err.contains(repeat[1]) && err.contains("cannot be used multiple times"), "{repeat:?}: {err}"); + } + let Invoke { code, stderr: err, .. } = invoke(&["convert", "a.png", "b.jpg", "--quality", "80", "--quality", "60"]); + assert_eq!(code, 2); + assert!(err.contains("--quality"), "{err}"); + // A value starting with `-` needs `=`; without it the token is a flag, never the value. A + // negative number is still a value. + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter=-zzz-no-match"]); + assert_eq!((code, out.as_str()), (0, ""), "{err}"); + for value in ["-zzz-no-match", "--json"] { + let Invoke { code, stdout: out, .. } = invoke(&["commands", "--filter", value]); + assert_eq!((code, out.is_empty()), (2, true), "--filter {value}"); + } + let Invoke { code, stderr: err, .. } = invoke(&["commands", "--filter", "-1"]); assert_eq!(code, 0, "{err}"); - assert_eq!(out, eq); - let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]); - assert_eq!(code, 0, "{err}"); - assert!(out.is_empty(), "{out}"); // A bare flag takes no value; a value flag needs one. let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]); assert_eq!((code, out.is_empty()), (2, true)); @@ -1147,3 +1164,15 @@ fn convert_opens_a_raw_at_the_raw_defaults_resolution() { assert_eq!(doc.resolution_dpi, 300.0); std::fs::remove_dir_all(d).unwrap(); } + +/// `--out=-image.png` writes `-image.png`; `--out -image.png` is a usage error and writes nothing. +#[test] +fn a_value_starting_with_a_dash_is_written_with_equals() { + let d = tmp("dash-value"); + let o = bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out", "-image.png"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0); + ok(bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out=-image.png"])); + assert!(d.join("-image.png").is_file()); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/crates/engine/src/build_info.rs b/crates/engine/src/build_info.rs index b7ed60e2fb..f110ada56e 100644 --- a/crates/engine/src/build_info.rs +++ b/crates/engine/src/build_info.rs @@ -33,6 +33,12 @@ pub fn long_version() -> String { format_long(VERSION, short_sha(), build_date()) } +/// [`long_version`] worked out once, for a `&'static str` such as clap's `version`. +pub fn long_version_static() -> &'static str { + static LONG: std::sync::OnceLock = std::sync::OnceLock::new(); + LONG.get_or_init(long_version) +} + fn format_long(version: &str, sha: Option<&str>, date: Option<&str>) -> String { match (sha, date) { (Some(s), Some(d)) => format!("{version} ({s}, {d})"), @@ -58,6 +64,7 @@ mod tests { fn version_is_the_workspace_version() { assert!(!VERSION.is_empty()); assert!(long_version().starts_with(VERSION)); + assert_eq!(long_version_static(), long_version()); if let Some(s) = short_sha() { assert!(s.len() <= 9); } From ef5688fec37cb8f5f5e13e4c6917eec9e2d84d46 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 10:57:44 +0300 Subject: [PATCH 19/40] refactor(cli): one run taking any OsString arguments (#7071) run took &[String] and run_os &[OsString], and run_os only checked each argument was Unicode before calling run. A single run generic over Into (as clap's try_get_matches_from is) does that check once and serves main's args_os and the tests' &str slices alike. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 30 ++++++++++++++--------------- apps/photocraft-cli/src/main.rs | 3 +-- apps/photocraft-cli/tests/it/cli.rs | 5 ++--- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 1ddbd4efbe..bf8b8e67e5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -3,6 +3,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::ffi::OsString; use std::io::Write; mod actions; @@ -20,13 +21,18 @@ use batch::{batch, droplet}; use document::{convert, info, run_cmds}; pub(crate) use error::{CliError, Result}; -/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error -/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts -/// the process on one (issue #1108). -pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 { - let mut strings = Vec::with_capacity(args.len()); - for (i, a) in args.iter().enumerate() { - match a.clone().into_string() { +/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok, +/// 1 failure, 2 usage). An argument that is not valid Unicode is a usage error naming its position +/// and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts the process on one +/// (issue #1108). +pub fn run(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32 +where + I: IntoIterator, + T: Into, +{ + let mut strings = Vec::new(); + for (i, a) in args.into_iter().map(Into::into).enumerate() { + match a.into_string() { Ok(s) => strings.push(s), Err(raw) => { let usage = ::command().render_usage(); @@ -35,14 +41,9 @@ pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Wr } } } - run(&strings, out, err) -} - -/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage). -pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { // Help and `--version` (`help`, `-h`, ` --help`, `-V`) come back as errors too, // for `out`, exit 0: the subcommand never runs. - let cli = match parse(args) { + let cli = match parse(&strings) { Ok(cli) => cli, Err(e) => { let to: &mut dyn Write = if e.use_stderr() { err } else { out }; @@ -80,8 +81,7 @@ pub(crate) mod test_support { pub(crate) fn invoke(args: &[&str]) -> Invoke { let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = crate::run(&args, &mut stdout, &mut stderr); + let code = crate::run(args, &mut stdout, &mut stderr); Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } } } diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs index c0bce7826b..2be0e13ae7 100644 --- a/apps/photocraft-cli/src/main.rs +++ b/apps/photocraft-cli/src/main.rs @@ -3,7 +3,6 @@ fn main() { // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a // usage error, not a panic before anything is printed (issue #1108). - let args: Vec = std::env::args_os().skip(1).collect(); - let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr()); + let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); std::process::exit(code); } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 81604f2568..c13da1e2d5 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -839,7 +839,7 @@ fn commands_listing() { fn in_process_run_matches_binary() { let mut out = Vec::new(); let mut err = Vec::new(); - let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err); + let code = photocraft_cli::run(["commands", "--filter", "file.new"], &mut out, &mut err); assert_eq!(code, 0); assert!(String::from_utf8(out).unwrap().contains("file.new")); } @@ -1018,8 +1018,7 @@ struct Invoke { fn invoke(args: &[&str]) -> Invoke { let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = photocraft_cli::run(&args, &mut stdout, &mut stderr); + let code = photocraft_cli::run(args, &mut stdout, &mut stderr); Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } } From a6a82b0dcaf04514e66761bbe49c535e04f1e7dd Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:43:29 +0300 Subject: [PATCH 20/40] docs(cli): describe repeated flags, dash values and --version (#7071) The CLI page said parsing reports malformed flags and JSON. Name what the step 0 decisions made a usage error: a single-value flag given twice (--cmd and --params repeat), how to pass a value starting with '-', and that `version` is only --version/-V. Say why --quality allows negative numbers. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/args.rs | 1 + book/src/automation/cli.md | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 9b4f67c84e..6df8d72ead 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -36,6 +36,7 @@ pub(crate) struct ExportArgs { #[arg(long, value_name = "EXT")] pub(crate) format: Option, /// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless + // allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument. #[arg(long, value_name = "1-100", allow_negative_numbers = true)] pub(crate) quality: Option, /// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index d33a8baad4..517a5d349e 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -63,9 +63,11 @@ layers because that would lose their group's blending semantics. Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment. -CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters. +CLI parsing reports malformed flags and JSON as errors. A flag the subcommand doesn't take (a typo such as `--fromat`) and a flag given twice (only `--cmd` and `--params` may repeat) are usage errors (exit status 2), rather than being ignored. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. -`mcp` and `serve` reject positional arguments, repeated options, missing or empty values and -options for the wrong mode before starting a server. Explicit control-token flags require -`mcp --bridge` or `serve --port`. Configure automation roots on the desktop process when using -bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples. +`mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before +starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port`. Configure +automation roots on the desktop process when using bridge mode; the CLI rejects root flags there. +See the [MCP launch guide](mcp.md) for examples. + +This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters. From f540d3f5e3941b4b1a3c413aab7603b91ed31bef Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 22 Oct 2026 21:04:27 +0300 Subject: [PATCH 21/40] refactor(cli): parse photocraft-cli values into types at the boundary (#7071) Step 3 of the issue: handlers get values that are already checked, so a bad one is a clap usage error naming its flag (exit 2) and the "bad --x" errors (exit 1) are gone. - --quality is a u8 in 1..=100; --tga-bits, --dpx-bits and --dds-compression parse to photocraft_io's TgaBits, DpxBits and DdsCompression through their from_bits/from_name, and --tiff-compression and --jpeg-format to photocraft_codecs' TiffCompression and JpegFormat, refusing a bad one with a typed ValueError; serve --port is a u16. export_opts becomes `impl From<&ExportArgs> for ExportOptions`, which can't fail. - run's --new and --params parse to serde_json::Value, so JSON that doesn't parse is a usage error; whether it is an object is still the command's to say (exit 1). The --cmd/--params pairing keeps using ArgMatches::indices_of, now over typed Step values. - Paths are PathBufs (convert, info, run, batch, the control token file and automation roots), and run hands the OsStrings to clap instead of refusing non-Unicode up front: a Latin-1 file name works as a path (#1108), and one given as text is clap's usage error. load_actions_file takes a &Path, and photocraft_engine's same_file takes any AsRef, so convert's #7437 check compares the PathBufs. - Rules live in the types: run needs exactly one of or --new (ArgGroup "source") and a --cmd or --out ("work"); batch's --actions, --in and --out are required; --control-token conflicts with --control-token-file. The runtime checks and their messages are deleted. The automation crate's own token check stays, as it also sees PHOTOCRAFT_CONTROL_TOKEN(_FILE). Tests changed for this: run_errors, quality_outside_1_to_100_is_refused, dpx_converts_both_ways_with_the_bits_asked_for, dds_converts_both_ways_with_the_compression_asked_for, tga_bits_choose_the_targa_depth, jpeg_format_chooses_the_scans (exit 2, the error names the flag) and the #1108 test, now a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error: convert reads and writes such a file name end to end, while info opens through the automation layer, whose paths must still be UTF-8, and refuses it (exit 1). either_or_flags_are_usage_errors is new. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/actions.rs | 12 +- apps/photocraft-cli/src/args.rs | 187 +++++++++++++++++--------- apps/photocraft-cli/src/automation.rs | 12 +- apps/photocraft-cli/src/batch.rs | 22 ++- apps/photocraft-cli/src/document.rs | 64 ++++----- apps/photocraft-cli/src/error.rs | 15 +-- apps/photocraft-cli/src/lib.rs | 20 +-- apps/photocraft-cli/src/main.rs | 4 +- apps/photocraft-cli/src/output.rs | 57 ++++---- apps/photocraft-cli/tests/it/cli.rs | 107 +++++++++++---- crates/engine/src/file_cmds.rs | 3 +- crates/engine/src/web_cmds/tests.rs | 4 +- 12 files changed, 292 insertions(+), 215 deletions(-) diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index cb75d62cb3..4d494c8a82 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -85,15 +85,15 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), ActionsError> { - let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.into(), source })?; - let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); +pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec, Vec), ActionsError> { + let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; + let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); if !is_atn { - let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.into(), source })?; + let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?; let (steps, set) = load_actions(&text, name)?; return Ok((steps, set, Vec::new())); } - let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.into(), message })?; + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; let action = match (name, imp.actions.as_slice()) { (Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction { name: n.to_owned(), @@ -101,7 +101,7 @@ pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec only, - (None, []) => return Err(ActionsError::EmptyAtn { path: path.into() }), + (None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }), (None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }), }; let warnings = imp diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 6df8d72ead..b64fbf92b0 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -1,13 +1,20 @@ -//! The command line, as clap types. Values stay strings: the handlers check them, with their own -//! messages (#7071). A flag a subcommand doesn't take is a usage error, so a typo such as -//! `--fromat` can't be silently ignored (#423), and so is a repeated one: `--quality 80 --quality -//! 60` is more likely a mistake than a choice. A value starting with `-` is written -//! `--out=-image.png` (or `./-image.png`), so a value flag never takes the next flag as its value; -//! negative numbers are kept where they mean something. +//! The command line, as clap types. Values are parsed into their types here, so a bad one is a +//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a +//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently +//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake +//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a +//! value flag never takes the next flag as its value; negative numbers are kept where they mean +//! something. + +use std::ffi::OsString; +use std::path::PathBuf; use clap::builder::NonEmptyStringValueParser; use clap::error::ErrorKind; -use clap::{ArgAction, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use photocraft_codecs::{JpegFormat, TiffCompression}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use serde_json::Value; /// photocraft-cli: headless PhotoCraft #[derive(Parser)] @@ -37,33 +44,33 @@ pub(crate) struct ExportArgs { pub(crate) format: Option, /// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless // allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument. - #[arg(long, value_name = "1-100", allow_negative_numbers = true)] - pub(crate) quality: Option, + #[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)] + pub(crate) quality: Option, /// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it #[arg(long)] pub(crate) tiff_layers: bool, /// Compress TIFF output: none (the default), lzw or zip - #[arg(long, value_name = "none|lzw|zip")] - pub(crate) tiff_compression: Option, + #[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)] + pub(crate) tiff_compression: Option, /// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its /// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24 - #[arg(long, value_name = "24|32")] - pub(crate) tga_bits: Option, + #[arg(long, value_name = "24|32", value_parser = tga_bits)] + pub(crate) tga_bits: Option, /// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16 - #[arg(long, value_name = "8|10|12|16")] - pub(crate) dpx_bits: Option, + #[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)] + pub(crate) dpx_bits: Option, /// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or /// off), bc2 (DXT3) or bc3 (DXT5) - #[arg(long, value_name = "none|bc1|bc2|bc3")] - pub(crate) dds_compression: Option, + #[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)] + pub(crate) dds_compression: Option, /// Also write a DDS texture's full mip chain #[arg(long)] pub(crate) dds_mipmaps: bool, /// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman /// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray /// and CMYK shrink) or progressive - #[arg(long, value_name = "baseline|optimized|progressive")] - pub(crate) jpeg_format: Option, + #[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)] + pub(crate) jpeg_format: Option, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -74,10 +81,10 @@ pub(crate) struct ExportArgs { pub(crate) struct ConvertArgs { /// The file to read #[arg(value_name = "IN")] - pub(crate) input: String, + pub(crate) input: PathBuf, /// The file to write; its extension picks the format unless --format is given #[arg(value_name = "OUT")] - pub(crate) output: String, + pub(crate) output: PathBuf, /// Allow an that is the file, replacing the original #[arg(long)] pub(crate) in_place: bool, @@ -88,7 +95,7 @@ pub(crate) struct ConvertArgs { /// Print the document as JSON (size, mode, depth, layer tree) #[derive(Args)] pub(crate) struct InfoArgs { - pub(crate) file: String, + pub(crate) file: PathBuf, /// One line of JSON instead of pretty-printed #[arg(long)] pub(crate) compact: bool, @@ -96,32 +103,41 @@ pub(crate) struct InfoArgs { /// Open a file (or start a new document), run engine commands in order, save the result /// -/// Prints each command's JSON result. Give exactly one of or --new. An --out that is the -/// is refused, as the result would replace the original; --in-place allows it. +/// Prints each command's JSON result. Give exactly one of or --new, and at least one --cmd +/// or --out. An --out that is the is refused, as the result would replace the original; +/// --in-place allows it. #[derive(Args)] +#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))] +#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))] pub(crate) struct RunArgs { /// The document to open - pub(crate) file: Option, + pub(crate) file: Option, /// Start from a new document with these JSON parameters instead of opening a file - #[arg(long, value_name = "JSON")] - pub(crate) new: Option, + #[arg(long, value_name = "JSON", value_parser = json)] + pub(crate) new: Option, /// An engine command to run; repeat for several, run in order #[arg(long, value_name = "ID", action = ArgAction::Append)] pub(crate) cmd: Vec, /// JSON parameters for the --cmd before it (default {}) - #[arg(long, value_name = "JSON", action = ArgAction::Append)] - pub(crate) params: Vec, + #[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)] + pub(crate) params: Vec, /// Save the result to this file #[arg(long, value_name = "FILE")] - pub(crate) out: Option, + pub(crate) out: Option, /// Allow an --out that is the , replacing the original #[arg(long)] pub(crate) in_place: bool, #[command(flatten)] pub(crate) export: ExportArgs, - /// `--cmd` and `--params` values in command-line order (`true` for a `--cmd`), set by [`parse`]. + /// `--cmd` and `--params` values in command-line order, set by [`parse`]. #[arg(skip)] - pub(crate) steps: Vec<(bool, String)>, + pub(crate) steps: Vec, +} + +/// One `--cmd` or `--params` of `run`, in command-line order. +pub(crate) enum Step { + Cmd(String), + Params(Value), } /// Apply an action list to every image in a directory @@ -136,16 +152,16 @@ pub(crate) struct RunArgs { pub(crate) struct BatchArgs { /// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn) #[arg(long, value_name = "FILE")] - pub(crate) actions: Option, + pub(crate) actions: PathBuf, /// Which action of an Actions set to play #[arg(long, value_name = "NAME")] pub(crate) action: Option, /// The folder of images to process #[arg(long = "in", value_name = "DIR")] - pub(crate) in_dir: Option, + pub(crate) in_dir: PathBuf, /// The folder to write the results to, as . #[arg(long, value_name = "DIR")] - pub(crate) out: Option, + pub(crate) out: PathBuf, /// An --out folder that is the --in folder is refused, as the results would replace the /// originals; --in-place allows it #[arg(long)] @@ -187,14 +203,14 @@ pub(crate) struct ControlArgs { pub(crate) control_token: Option, /// A file holding the control token, used instead of --control-token (a server creates it /// when missing) - #[arg(long, value_name = "PATH", value_parser = NonEmptyStringValueParser::new())] - pub(crate) control_token_file: Option, + #[arg(long, value_name = "PATH")] + pub(crate) control_token_file: Option, /// The folder automation may read from (none without it) - #[arg(long, value_name = "DIR", value_parser = NonEmptyStringValueParser::new())] - pub(crate) automation_read_root: Option, + #[arg(long, value_name = "DIR")] + pub(crate) automation_read_root: Option, /// The folder automation may write to (none without it) - #[arg(long, value_name = "DIR", value_parser = NonEmptyStringValueParser::new())] - pub(crate) automation_write_root: Option, + #[arg(long, value_name = "DIR")] + pub(crate) automation_write_root: Option, } /// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `) @@ -216,33 +232,82 @@ pub(crate) struct McpArgs { #[derive(Args)] pub(crate) struct ServeArgs { /// Listen on 127.0.0.1: instead of stdio - #[arg(long, value_name = "PORT", value_parser = NonEmptyStringValueParser::new())] - pub(crate) port: Option, + #[arg(long, value_name = "PORT")] + pub(crate) port: Option, #[command(flatten)] pub(crate) control: ControlArgs, } -/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart, -/// their indices say how they interleave. -fn steps(m: &ArgMatches) -> Vec<(bool, String)> { - let mut steps: Vec<(usize, bool, String)> = Vec::new(); - for (id, is_cmd) in [("cmd", true), ("params", false)] { - if let (Some(at), Some(values)) = (m.indices_of(id), m.try_get_many::(id).ok().flatten()) { - steps.extend(at.zip(values).map(|(i, v)| (i, is_cmd, v.clone()))); - } - } - steps.sort_by_key(|s| s.0); - steps.into_iter().map(|(_, is_cmd, v)| (is_cmd, v)).collect() +/// Why a value was refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +enum ValueError { + #[error("expected 24 or 32")] + TgaBits, + #[error("expected 8, 10, 12 or 16")] + DpxBits, + #[error("expected none, bc1, bc2 or bc3")] + DdsCompression, + #[error("expected none, lzw or zip")] + TiffCompression, + #[error("expected baseline, optimized or progressive")] + JpegFormat, } -/// Parse the arguments (without the binary name). Help where a flag's value should be -/// (`--out --help`) is still help: clap reports the missing value first. -pub(crate) fn parse(args: &[String]) -> Result { +fn tga_bits(s: &str) -> Result { + s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits) +} + +fn dpx_bits(s: &str) -> Result { + s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits) +} + +fn dds_compression(s: &str) -> Result { + DdsCompression::from_name(s).ok_or(ValueError::DdsCompression) +} + +fn tiff_compression(s: &str) -> Result { + match s { + "none" => Ok(TiffCompression::None), + "lzw" => Ok(TiffCompression::Lzw), + "zip" => Ok(TiffCompression::Deflate), + _ => Err(ValueError::TiffCompression), + } +} + +fn jpeg_format(s: &str) -> Result { + JpegFormat::parse(s).ok_or(ValueError::JpegFormat) +} + +/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an +/// object is the command's to say. +fn json(s: &str) -> Result { + serde_json::from_str(s) +} + +/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart, +/// their indices say how they interleave. +fn steps(m: &ArgMatches) -> Vec { + let mut steps: Vec<(usize, Step)> = Vec::new(); + if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::("cmd")) { + steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone())))); + } + if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::("params")) { + steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone())))); + } + steps.sort_by_key(|s| s.0); + steps.into_iter().map(|(_, s)| s).collect() +} + +/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not +/// be valid Unicode (#1108), text must. +pub(crate) fn parse(args: &[OsString]) -> Result { let mut cmd = Cli::command(); - let given: Vec<&str> = std::iter::once("photocraft-cli").chain(args.iter().map(String::as_str)).collect(); - let matches = match cmd.try_get_matches_from_mut(given.iter().copied()) { - Err(e) if e.kind() == ErrorKind::InvalidValue && given.iter().take_while(|a| **a != "--").any(|a| matches!(*a, "--help" | "-h")) => { - let help = ["photocraft-cli", given.get(1).copied().unwrap_or_default(), "--help"]; + let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned()); + let matches = match cmd.try_get_matches_from_mut(argv) { + // Help where a flag's value should be (`--out --help`) is still help: clap reports the + // missing value first. + Err(e) if e.kind() == ErrorKind::InvalidValue && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => { + let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")]; return Err(Cli::command().try_get_matches_from(help).err().unwrap_or(e)); } matches => matches?, diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index 09a83680ef..ff7a21eb80 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -1,18 +1,17 @@ use std::io::Write; -use std::path::{Path, PathBuf}; use serde_json::Value; use photocraft_automation::{AuthorizedWorkspace, AutomationError, Headless, PhotocraftMcp, security}; use crate::{ - CliError, Result, + Result, args::{CommandsArgs, ControlArgs, McpArgs, ServeArgs}, output::{JsonStyle, print_json}, }; pub(crate) fn automation_workspace(args: &ControlArgs) -> Result { - AuthorizedWorkspace::new(args.automation_read_root.as_deref().map(Path::new), args.automation_write_root.as_deref().map(Path::new)) + AuthorizedWorkspace::new(args.automation_read_root.as_deref(), args.automation_write_root.as_deref()) } pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { @@ -39,11 +38,10 @@ pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { use std::sync::{Arc, Mutex}; let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(&a.control)?))); - match &a.port { + match a.port { Some(port) => { - let port: u16 = port.parse().map_err(|_| CliError::BadPort(port.to_owned()))?; let addr = format!("127.0.0.1:{port}"); - let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.as_deref().map(PathBuf::from)); + let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); let token = security::server_token(supplied.as_deref(), token_file.as_deref())?; if let Some(path) = &token_file { let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); @@ -65,7 +63,7 @@ pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { pub(crate) fn mcp(a: &McpArgs) -> Result { let server = match &a.bridge { Some(addr) => { - let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.as_deref().map(PathBuf::from)); + let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); let token = security::client_token(supplied.as_deref(), token_file.as_deref())?; PhotocraftMcp::bridge(addr, &token)? } diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index 4e44c549b7..bdaf249bc9 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -3,13 +3,14 @@ use std::path::{Path, PathBuf}; use photocraft_automation::Headless; use photocraft_engine::actions_cmds::Action; +use photocraft_io::ExportOptions; use serde_json::json; use crate::{ CliError, Result, actions::load_actions_file, args::{BatchArgs, DropletArgs}, - output::{export_opts, warn_all}, + output::warn_all, }; /// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a @@ -47,22 +48,19 @@ fn strip_shell_steps(action: &mut Action) { } pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { - let needs = |what| CliError::Needs { sub: "batch", what }; - let actions_path = a.actions.as_deref().ok_or_else(|| needs("--actions "))?; - let in_dir = PathBuf::from(a.in_dir.as_deref().ok_or_else(|| needs("--in "))?); - let out_dir = PathBuf::from(a.out.as_deref().ok_or_else(|| needs("--out "))?); - let (actions, mut set, warnings) = load_actions_file(actions_path, a.action.as_deref())?; + let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path()); + let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?; set.iter_mut().for_each(strip_shell_steps); warn_all(err, &warnings); - let opts = export_opts(&a.export)?; + let opts = ExportOptions::from(&a.export); // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). - if !a.in_place && same_dir(&in_dir, &out_dir) { - return Err(CliError::OutIsIn(out_dir)); + if !a.in_place && same_dir(in_dir, out_dir) { + return Err(CliError::OutIsIn(out_dir.to_path_buf())); } - std::fs::create_dir_all(&out_dir)?; - let mut inputs: Vec = std::fs::read_dir(&in_dir) - .map_err(|source| CliError::ReadDir { path: in_dir.clone(), source })? + std::fs::create_dir_all(out_dir)?; + let mut inputs: Vec = std::fs::read_dir(in_dir) + .map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })? .flatten() .map(|e| e.path()) .filter(|p| is_input(p)) diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 48051a09b8..9d730b4635 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -5,11 +5,12 @@ use photocraft_automation::{Headless, files}; use serde_json::{Value, json}; use photocraft_engine::type_extra_cmds::missing_font_warnings; +use photocraft_io::ExportOptions; use crate::{ CliError, Result, - args::{ConvertArgs, InfoArgs, RunArgs}, - output::{JsonStyle, export_opts, print_json, warn_all}, + args::{ConvertArgs, InfoArgs, RunArgs, Step}, + output::{JsonStyle, print_json, warn_all}, }; /// An output that is the input file itself, however it is spelt (relative, with `./`, through a @@ -18,23 +19,23 @@ use crate::{ /// folder (#7437). The paths are compared when the command starts and the save opens `output` /// again later, so another process swapping in a link meanwhile goes unnoticed: this guards /// against mistakes, it is not a security boundary. -fn refuse_overwriting_input(in_place: bool, input: &str, output: &str) -> Result { +fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Result { if !in_place && photocraft_engine::file_cmds::same_file(input, output) { - return Err(CliError::OutIsInput(output.to_owned())); + return Err(CliError::OutIsInput(output.to_path_buf())); } Ok(()) } pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result { let (input, output) = (&a.input, &a.output); - let opts = export_opts(&a.export)?; + let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let mut o = files::open(Path::new(input))?; + let mut o = files::open(input)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); warn_all(err, &missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.export.format.as_deref(), &opts, None)?; + let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?; warn_all(err, &ws); Ok(()) } @@ -42,14 +43,14 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { let file = &a.file; let mut h = Headless::trusted_local(); - let opened = h.open(Path::new(file))?; + let opened = h.open(file)?; let mut doc = h.inspect(None)?; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); if let Some(st) = h.session.active() { warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String)); } doc["warnings"] = Value::Array(warnings); - doc["file"] = json!(file); + doc["file"] = json!(file.to_string_lossy()); // Drop per-session noise. if let Value::Object(m) = &mut doc { for k in ["history", "canUndo", "canRedo", "revision"] { @@ -62,44 +63,35 @@ pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { /// `(id, params)` pairs from repeated `--cmd`/`--params` flags. fn command_list(a: &RunArgs) -> Result> { let mut cmds: Vec<(String, Value)> = Vec::new(); - for (is_cmd, v) in &a.steps { - if *is_cmd { - cmds.push((v.clone(), json!({}))); - } else { - let last = cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?; - last.1 = serde_json::from_str(v).map_err(|source| CliError::BadParams { id: last.0.clone(), source })?; + for step in &a.steps { + match step { + Step::Cmd(id) => cmds.push((id.clone(), json!({}))), + Step::Params(p) => cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?.1 = p.clone(), } } Ok(cmds) } pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { - let opts = export_opts(&a.export)?; + let opts = ExportOptions::from(&a.export); let mut h = Headless::trusted_local(); // Opening, running commands and exporting can all notice the same missing font: // one `run` invocation reports each warning line at most once. let mut reported_fonts = std::collections::HashSet::::new(); - match (a.file.as_deref(), a.new.as_deref()) { - (Some(file), None) => { - // Refused before the file is read or any command runs. - if let Some(out) = &a.out { - refuse_overwriting_input(a.in_place, file, out)?; - } - let o = h.open(Path::new(file))?; - let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); - warn_all(err, &ws); - reported_fonts.extend(ws); + // clap's `source` group gives exactly one of the two, and its `work` group a --cmd or --out. + if let Some(new) = &a.new { + h.command_run("file.new", new.clone()).map_err(CliError::New)?; + } else if let Some(file) = &a.file { + // Refused before the file is read or any command runs. + if let Some(out) = &a.out { + refuse_overwriting_input(a.in_place, file, out)?; } - (None, Some(new)) => { - let p: Value = serde_json::from_str(new).map_err(CliError::BadNew)?; - h.command_run("file.new", p).map_err(CliError::New)?; - } - _ => return Err(CliError::Needs { sub: "run", what: "exactly one of or --new " }), + let o = h.open(file)?; + let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); + warn_all(err, &ws); + reported_fonts.extend(ws); } let cmds = command_list(a)?; - if cmds.is_empty() && a.out.is_none() { - return Err(CliError::Needs { sub: "run", what: "at least one --cmd (or --out)" }); - } for (id, params) in cmds { let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?; if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { @@ -118,8 +110,8 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> let remaining: Vec = h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &remaining); - if let Some(o) = &a.out { - let r = h.save(None, Some(Path::new(o)), a.export.format.as_deref(), &opts)?; + if let Some(o) = a.out.as_deref() { + let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs index 99cbd7574f..3e4aa3caef 100644 --- a/apps/photocraft-cli/src/error.rs +++ b/apps/photocraft-cli/src/error.rs @@ -17,17 +17,6 @@ pub(crate) enum CliError { Json(#[from] serde_json::Error), #[error(transparent)] Actions(#[from] ActionsError), - /// A required argument is missing: `convert needs `. - #[error("{sub} needs {what}")] - Needs { sub: &'static str, what: &'static str }, - #[error("bad {flag} `{value}`: expected {expected}")] - BadValue { flag: &'static str, value: String, expected: &'static str }, - #[error("bad --port `{0}`")] - BadPort(String), - #[error("bad --new JSON: {0}")] - BadNew(#[source] serde_json::Error), - #[error("bad --params JSON for `{id}`: {source}")] - BadParams { id: String, source: serde_json::Error }, #[error("{}: {source}", path.display())] ReadDir { path: PathBuf, source: std::io::Error }, #[error("--params must follow a --cmd")] @@ -39,8 +28,8 @@ pub(crate) enum CliError { /// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]). #[error("`{id}`: {message}")] NestedFailure { id: String, message: String }, - #[error("{0} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")] - OutIsInput(String), + #[error("{} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())] + OutIsInput(PathBuf), #[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())] OutIsIn(PathBuf), /// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]). diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index bf8b8e67e5..c01c211eeb 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -22,28 +22,18 @@ use document::{convert, info, run_cmds}; pub(crate) use error::{CliError, Result}; /// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok, -/// 1 failure, 2 usage). An argument that is not valid Unicode is a usage error naming its position -/// and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts the process on one -/// (issue #1108). +/// 1 failure, 2 usage). clap reads the arguments as they are: a path that is not valid Unicode (a +/// Latin-1 file name on Linux) is taken, and one where text is expected is a usage error (exit 2). +/// Never a panic, as `std::env::args()` aborts on one (issue #1108). pub fn run(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32 where I: IntoIterator, T: Into, { - let mut strings = Vec::new(); - for (i, a) in args.into_iter().map(Into::into).enumerate() { - match a.into_string() { - Ok(s) => strings.push(s), - Err(raw) => { - let usage = ::command().render_usage(); - let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{usage}", i + 1, raw.to_string_lossy()); - return 2; - } - } - } + let args: Vec = args.into_iter().map(Into::into).collect(); // Help and `--version` (`help`, `-h`, ` --help`, `-V`) come back as errors too, // for `out`, exit 0: the subcommand never runs. - let cli = match parse(&strings) { + let cli = match parse(&args) { Ok(cli) => cli, Err(e) => { let to: &mut dyn Write = if e.use_stderr() { err } else { out }; diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs index 2be0e13ae7..ea5598f99d 100644 --- a/apps/photocraft-cli/src/main.rs +++ b/apps/photocraft-cli/src/main.rs @@ -1,8 +1,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { - // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a - // usage error, not a panic before anything is printed (issue #1108). + // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap, + // not panic before anything is printed (issue #1108). let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); std::process::exit(code); } diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 08797bde37..34101c9ec0 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -2,41 +2,32 @@ use photocraft_io::ExportOptions; use serde_json::Value; use std::io::Write; -use crate::{CliError, Result, args::ExportArgs}; +use crate::{Result, args::ExportArgs}; -pub(crate) fn export_opts(a: &ExportArgs) -> Result { - let bad = |flag, value: &str, expected| CliError::BadValue { flag, value: value.to_owned(), expected }; - // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.tiff_layers, ..Default::default() }; - if let Some(q) = &a.quality { - let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| bad("--quality", q, "a whole number from 1 to 100"))?; - o.encode.jpeg_quality = q; - // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. - o.encode.webp_quality = q; - o.encode.webp_lossless = false; +/// The `--format` family's options. A bad value never gets here: clap refused it. +impl From<&ExportArgs> for ExportOptions { + fn from(a: &ExportArgs) -> Self { + // TIFF output is flat unless asked: scripted conversions keep predictable sizes. + let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() }; + if let Some(q) = a.quality { + o.encode.jpeg_quality = q; + // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. + o.encode.webp_quality = q; + o.encode.webp_lossless = false; + } + o.encode.dpx_bits = a.dpx_bits; + if let Some(c) = a.tiff_compression { + o.encode.tiff_compression = c; + } + if let Some(f) = a.jpeg_format { + o.encode.jpeg_format = f; + } + if let Some(c) = a.dds_compression { + o.encode.dds.compression = c; + } + o.encode.dds.mipmaps = a.dds_mipmaps; + o } - if let Some(b) = &a.tga_bits { - o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| bad("--tga-bits", b, "24 or 32"))?); - } - if let Some(b) = &a.dpx_bits { - o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| bad("--dpx-bits", b, "8, 10, 12 or 16"))?); - } - if let Some(c) = &a.tiff_compression { - o.encode.tiff_compression = match c.as_str() { - "none" => photocraft_codecs::TiffCompression::None, - "lzw" => photocraft_codecs::TiffCompression::Lzw, - "zip" => photocraft_codecs::TiffCompression::Deflate, - _ => return Err(bad("--tiff-compression", c, "none, lzw or zip")), - }; - } - if let Some(f) = &a.jpeg_format { - o.encode.jpeg_format = photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| bad("--jpeg-format", f, "baseline, optimized or progressive"))?; - } - if let Some(c) = &a.dds_compression { - o.encode.dds.compression = photocraft_io::DdsCompression::from_name(c).ok_or_else(|| bad("--dds-compression", c, "none, bc1, bc2 or bc3"))?; - } - o.encode.dds.mipmaps = a.dds_mipmaps; - Ok(o) } pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) { diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index c13da1e2d5..e5859f1ea1 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -376,10 +376,13 @@ fn run_errors() { let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command")); + // Neither a --cmd nor --out, and JSON that doesn't parse, are usage errors naming the flag. let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--cmd")); let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--new")); // Params must be a JSON object, for `--new` and for each `--params`. let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); @@ -542,8 +545,8 @@ fn batch_format_with_leading_dot() { } } -/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size, -/// and nothing is written. +/// #491: `--quality` outside 1-100 is a usage error for every subcommand, whatever the number's +/// size, and nothing is written. #[test] fn quality_outside_1_to_100_is_refused() { let d = tmp("quality"); @@ -559,12 +562,12 @@ fn quality_outside_1_to_100_is_refused() { run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]); for cmd in [&mut convert, &mut run] { let o = cmd.output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--quality {q}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}"); + assert_eq!(o.status.code(), Some(2), "--quality {q}"); + assert!(String::from_utf8_lossy(&o.stderr).contains("--quality"), "--quality {q}"); assert!(!out.exists(), "--quality {q} wrote a file"); } let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]); - assert_eq!(o.status.code(), Some(1), "batch --quality {q}"); + assert_eq!(o.status.code(), Some(2), "batch --quality {q}"); assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder"); } // The documented range still encodes, lower quality making a smaller file. @@ -580,7 +583,7 @@ fn quality_outside_1_to_100_is_refused() { } /// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the -/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is a usage error (#1049). #[test] fn dpx_converts_both_ways_with_the_bits_asked_for() { let d = tmp("dpx"); @@ -607,15 +610,16 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { for bad in ["24", "0", "abc"] { let out = d.join("bad.dpx"); let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--dpx-bits {bad}"); + assert!(err.contains("--dpx-bits") && err.contains("expected 8, 10, 12 or 16"), "--dpx-bits {bad}: {err}"); assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); } } /// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes /// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that -/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327). +/// isn't none, bc1, bc2 or bc3 is a usage error and nothing is written (#1327). #[test] fn dds_converts_both_ways_with_the_compression_asked_for() { let d = tmp("dds"); @@ -640,14 +644,14 @@ fn dds_converts_both_ways_with_the_compression_asked_for() { for bad in ["bc7", "dxt2", ""] { let out = d.join("bad.dds"); let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}"); + assert_eq!(o.status.code(), Some(2), "--dds-compression {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains("--dds-compression"), "--dds-compression {bad}"); assert!(!out.exists(), "--dds-compression {bad} wrote a file"); } } -/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is -/// refused and nothing is written. +/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a +/// usage error and nothing is written. #[test] fn tga_bits_choose_the_targa_depth() { let d = tmp("tga-bits"); @@ -666,8 +670,9 @@ fn tga_bits_choose_the_targa_depth() { for bad in ["16", "0", "abc", ""] { let out = d.join("bad.tga"); let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}"); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--tga-bits {bad}"); + assert!(err.contains("--tga-bits") && err.contains("expected 24 or 32"), "--tga-bits {bad}: {err}"); assert!(!out.exists(), "--tga-bits {bad} wrote a file"); } } @@ -713,7 +718,7 @@ fn run_refuses_to_write_over_its_input() { } /// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it -/// doesn't name is refused and nothing is written. +/// doesn't name is a usage error and nothing is written. #[test] fn jpeg_format_chooses_the_scans() { let d = tmp("jpeg-format"); @@ -733,8 +738,9 @@ fn jpeg_format_chooses_the_scans() { for bad in ["Progressive", "1", ""] { let out = d.join("bad.jpg"); let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}"); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--jpeg-format {bad}"); + assert!(err.contains("--jpeg-format") && err.contains("expected baseline, optimized or progressive"), "--jpeg-format {bad}: {err}"); assert!(!out.exists(), "--jpeg-format {bad} wrote a file"); } } @@ -964,19 +970,38 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() { } /// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in -/// `std::env::args()` before anything was printed. It is a usage error naming the argument. +/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path +/// takes one, and text (`--filter`) refuses it with a usage error. #[cfg(unix)] #[test] -fn a_non_unicode_argument_is_a_usage_error_not_a_panic() { +fn a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error() { + use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png"); - for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] { - let o = bin().args(&args).output().unwrap(); + let d = tmp("non-unicode"); + let name = OsStr::from_bytes(b"caf\xe9.png"); + // No such file yet: the handler runs and fails on it (exit 1), no usage error. + for args in [vec![OsStr::new("info"), name], vec![OsStr::new("convert"), name, OsStr::new("out.png")]] { + let o = bin().current_dir(&d).args(&args).output().unwrap(); let err = String::from_utf8_lossy(&o.stderr); - assert_eq!(o.status.code(), Some(2), "{err}"); - assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}"); + assert_eq!(o.status.code(), Some(1), "{args:?}: {err}"); assert!(!err.contains("panicked"), "{err}"); } + // Linux file systems take the name (macOS ones refuse it). `convert` reads it; `info` opens + // through the automation layer, which still needs UTF-8 paths, and says so (exit 1). + if cfg!(target_os = "linux") { + write_png(&d.join(name), 8, 4, 3); + ok(bin().current_dir(&d).arg("convert").arg(name).arg("out.png")); + assert!(d.join("out.png").is_file()); + let o = bin().current_dir(&d).arg("info").arg(name).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{err}"); + assert!(err.contains("UTF-8"), "{err}"); + } + let o = bin().args([OsStr::new("commands"), OsStr::new("--filter"), name]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!((o.status.code(), o.stdout.is_empty()), (Some(2), true), "{err}"); + assert!(err.contains("UTF-8") && !err.contains("panicked"), "{err}"); + std::fs::remove_dir_all(d).unwrap(); } /// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps @@ -1175,3 +1200,31 @@ fn a_value_starting_with_a_dash_is_written_with_equals() { assert!(d.join("-image.png").is_file()); std::fs::remove_dir_all(d).unwrap(); } + +/// `run` takes exactly one of `` or `--new`, and `mcp`/`serve` one of `--control-token` or +/// `--control-token-file`: anything else is a usage error naming the flags, before any work. +#[test] +fn either_or_flags_are_usage_errors() { + let Invoke { code, stderr: err, .. } = invoke(&["run", "a.png", "--new", "{}", "--cmd", "layer.new.layer"]); + assert_eq!(code, 2); + assert!(err.contains("--new") && err.contains("cannot be used with"), "{err}"); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--cmd", "layer.new.layer"]); + assert_eq!(code, 2); + assert!(err.contains("--new"), "{err}"); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "{"]); + assert_eq!(code, 2); + assert!(err.contains("--params"), "{err}"); + let token = "a".repeat(64); + for sub in ["mcp", "serve"] { + let o = bin().args([sub, "--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{sub}: {err}"); + assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{sub}: {err}"); + } + for port in ["70000", "x"] { + let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--port {port}: {err}"); + assert!(err.contains("--port"), "--port {port}: {err}"); + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 6e81656bdb..261e3d8608 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -229,7 +229,8 @@ pub(crate) fn stem(path: &str) -> String { /// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on /// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them). -pub fn same_file(a: &str, b: &str) -> bool { +pub fn same_file(a: impl AsRef, b: impl AsRef) -> bool { + let (a, b) = (a.as_ref(), b.as_ref()); if a == b { return true; } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 8978234d7d..38fa82c188 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -513,13 +513,13 @@ fn same_file_sees_through_other_names_for_one_file() { std::fs::write(&a, b"a").unwrap(); std::fs::write(&c, b"c").unwrap(); assert!(crate::file_cmds::same_file(&a, &a)); - assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); + assert!(crate::file_cmds::same_file(&a, format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); std::fs::hard_link(&a, &b).unwrap(); assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] - assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); + assert!(crate::file_cmds::same_file(&a, format!("{dir}/A.PNG")), "NTFS names ignore case"); } #[test] From 7cb986a02dd9754dcc0312886348b2af444fa2c7 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 15:05:41 +0300 Subject: [PATCH 22/40] docs(cli): describe the typed value and either/or checks (#7071) Name what typed values made a usage error: a value out of range (--quality 0), malformed --new or --params JSON, together with --new, and --control-token together with --control-token-file. A path that is not valid Unicode now reaches the command: convert takes it, info, run and batch fail through the automation layer (exit 1), and droplet, which takes text, refuses it as a usage error. Co-authored-by: Claude Opus 5.5 --- book/src/automation/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 517a5d349e..4b2b068372 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -63,7 +63,7 @@ layers because that would lose their group's blending semantics. Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment. -CLI parsing reports malformed flags and JSON as errors. A flag the subcommand doesn't take (a typo such as `--fromat`) and a flag given twice (only `--cmd` and `--params` may repeat) are usage errors (exit status 2), rather than being ignored. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. +CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. `mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port`. Configure From 2f13a1564c07afdbcad3a0ec7dd437cb4bd7ce36 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:57:44 +0300 Subject: [PATCH 23/40] refactor(cli): share ControlArgs and AutomationArgs, typed --bridge (#7071) Step 4 of the issue: the control token flags and the automation roots move to photocraft-automation's new args module as two clap structs, which mcp and serve flatten and the desktop app can flatten in step 5 without moving them again. clap is a plain dependency of the crate. - ControlArgs holds --control-token and --control-token-file; AutomationArgs holds the roots, and AutomationArgs::workspace opens them (replacing the CLI's automation_workspace). - The port value parser is photocraft_automation::args::control_port, with its unit tests, for serve --port and --bridge now and the desktop app's --control later. It ignores surrounding spaces and refuses anything else with a typed NotAPort. - --bridge parses to Bridge::Port or Bridge::Addr: it takes a bare port, meaning 127.0.0.1:, or any value containing a ':', which the bridge client then validates. Anything else is a usage error (exit 2) instead of a bridge error. - 127.0.0.1 addresses are built from Ipv4Addr::LOCALHOST. mcp_bridge_takes_a_port_or_an_address is new: `--bridge nope` is a usage error, and `--bridge 1` bridges to 127.0.0.1:1, which a tool call names when nothing listens there. No new environment behaviour: the token variables are still read at run time through security::token_inputs, and the roots stay flag-only. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft-cli/src/args.rs | 65 +++++++++++++++--------- apps/photocraft-cli/src/automation.rs | 17 +++---- apps/photocraft-cli/tests/it/cli.rs | 73 ++++++++++++++++++++++----- crates/automation/Cargo.toml | 1 + crates/automation/src/args.rs | 72 ++++++++++++++++++++++++++ crates/automation/src/lib.rs | 1 + docs/control-protocol.md | 2 +- 8 files changed, 184 insertions(+), 48 deletions(-) create mode 100644 crates/automation/src/args.rs diff --git a/Cargo.lock b/Cargo.lock index 5c37fc0b8e..5c9b56c62f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,6 +4790,7 @@ version = "0.15.1" dependencies = [ "base64 0.22.1", "cap-std", + "clap", "getrandom 0.3.4", "photocraft-codecs", "photocraft-compose", diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index b64fbf92b0..c7b8c599dd 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -7,11 +7,13 @@ //! something. use std::ffi::OsString; +use std::fmt; +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; -use clap::builder::NonEmptyStringValueParser; use clap::error::ErrorKind; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use photocraft_automation::args::{AutomationArgs, ControlArgs, control_port}; use photocraft_codecs::{JpegFormat, TiffCompression}; use photocraft_io::{DdsCompression, DpxBits, TgaBits}; use serde_json::Value; @@ -195,32 +197,17 @@ pub(crate) struct CommandsArgs { pub(crate) filter: Option, } -// The control token and the folders automation may touch: shared by `mcp` and `serve`. -#[derive(Args)] -pub(crate) struct ControlArgs { - /// The control token (64 hex digits) - #[arg(long, value_name = "64-HEX", value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] - pub(crate) control_token: Option, - /// A file holding the control token, used instead of --control-token (a server creates it - /// when missing) - #[arg(long, value_name = "PATH")] - pub(crate) control_token_file: Option, - /// The folder automation may read from (none without it) - #[arg(long, value_name = "DIR")] - pub(crate) automation_read_root: Option, - /// The folder automation may write to (none without it) - #[arg(long, value_name = "DIR")] - pub(crate) automation_write_root: Option, -} - /// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `) #[derive(Args)] pub(crate) struct McpArgs { - /// Bridge to the PhotoCraft app listening here instead of a headless engine - #[arg(long, value_name = "127.0.0.1:PORT", value_parser = NonEmptyStringValueParser::new())] - pub(crate) bridge: Option, + /// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means + /// 127.0.0.1: + #[arg(long, value_name = "127.0.0.1:PORT", value_parser = bridge)] + pub(crate) bridge: Option, #[command(flatten)] pub(crate) control: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, } /// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1: @@ -232,10 +219,12 @@ pub(crate) struct McpArgs { #[derive(Args)] pub(crate) struct ServeArgs { /// Listen on 127.0.0.1: instead of stdio - #[arg(long, value_name = "PORT")] + #[arg(long, value_name = "PORT", value_parser = control_port)] pub(crate) port: Option, #[command(flatten)] pub(crate) control: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, } /// Why a value was refused: clap prints it after the flag and its value (exit 2). @@ -251,6 +240,9 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + /// Why a `--bridge` value is refused. + #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] + Bridge, } fn tga_bits(s: &str) -> Result { @@ -278,6 +270,33 @@ fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } +/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. +#[derive(Clone)] +pub(crate) enum Bridge { + /// A bare port: the app on 127.0.0.1. + Port(u16), + /// `host:port`, as given. Whether the host is loopback is the bridge's to say. + Addr(String), +} + +impl fmt::Display for Bridge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f), + Bridge::Addr(addr) => f.write_str(addr), + } + } +} + +/// `mcp --bridge`: a port, or `host:port`. +fn bridge(s: &str) -> Result { + match control_port(s) { + Ok(port) => Ok(Bridge::Port(port)), + Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())), + Err(_) => Err(ValueError::Bridge), + } +} + /// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an /// object is the command's to say. fn json(s: &str) -> Result { diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index ff7a21eb80..07aab0169a 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -1,19 +1,16 @@ use std::io::Write; +use std::net::{Ipv4Addr, SocketAddr}; use serde_json::Value; -use photocraft_automation::{AuthorizedWorkspace, AutomationError, Headless, PhotocraftMcp, security}; +use photocraft_automation::{Headless, PhotocraftMcp, security}; use crate::{ Result, - args::{CommandsArgs, ControlArgs, McpArgs, ServeArgs}, + args::{CommandsArgs, McpArgs, ServeArgs}, output::{JsonStyle, print_json}, }; -pub(crate) fn automation_workspace(args: &ControlArgs) -> Result { - AuthorizedWorkspace::new(args.automation_read_root.as_deref(), args.automation_write_root.as_deref()) -} - pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { let h = Headless::new(); let list = h.command_list(); @@ -37,10 +34,10 @@ pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { use std::sync::{Arc, Mutex}; - let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(&a.control)?))); + let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?))); match a.port { Some(port) => { - let addr = format!("127.0.0.1:{port}"); + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string(); let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); let token = security::server_token(supplied.as_deref(), token_file.as_deref())?; if let Some(path) = &token_file { @@ -65,9 +62,9 @@ pub(crate) fn mcp(a: &McpArgs) -> Result { Some(addr) => { let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); let token = security::client_token(supplied.as_deref(), token_file.as_deref())?; - PhotocraftMcp::bridge(addr, &token)? + PhotocraftMcp::bridge(&addr.to_string(), &token)? } - None => PhotocraftMcp::headless_with_workspace(automation_workspace(&a.control)?), + None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?), }; let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; Ok(rt.block_on(server.serve_stdio())?) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index e5859f1ea1..e242bf6084 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -855,20 +855,8 @@ fn mcp_stdio_handshake_and_tool_call() { let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); - let mut send = |v: Value| { - writeln!(stdin, "{v}").unwrap(); - stdin.flush().unwrap(); - }; - let mut recv = |id: u64| -> Value { - loop { - let mut line = String::new(); - assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed"); - let v: Value = serde_json::from_str(&line).unwrap(); - if v["id"] == id { - return v; - } - } - }; + let mut send = |v: Value| mcp_send(&mut stdin, v); + let mut recv = |id: u64| mcp_recv(&mut stdout, id); send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}})); let init = recv(1); @@ -888,6 +876,63 @@ fn mcp_stdio_handshake_and_tool_call() { let _ = child.wait(); } +/// Send one JSON-RPC line to an `mcp` child. +fn mcp_send(stdin: &mut impl Write, v: Value) { + writeln!(stdin, "{v}").unwrap(); + stdin.flush().unwrap(); +} + +/// Read an `mcp` child's lines up to the reply to `id`. +fn mcp_recv(stdout: &mut impl BufRead, id: u64) -> Value { + loop { + let mut line = String::new(); + assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed"); + let v: Value = serde_json::from_str(&line).unwrap(); + if v["id"] == id { + return v; + } + } +} + +/// `mcp --bridge` takes a port or anything with a `:`; anything else is a usage error. A bare port +/// means 127.0.0.1, and the bridge connects at the first call, so with no app there the call fails +/// naming that address. +#[test] +fn mcp_bridge_takes_a_port_or_an_address() { + let o = bin().args(["mcp", "--bridge", "nope"]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{err}"); + assert!(err.contains("invalid value 'nope' for '--bridge"), "{err}"); + assert!(err.contains("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)"), "{err}"); + + // Nothing listens on port 1 in a test run. The token only has to be well-formed. + let token = "a".repeat(64); + let mut child = bin() + .args(["mcp", "--bridge", "1", "--control-token", token.as_str()]) + .env_remove("PHOTOCRAFT_CONTROL_TOKEN") + .env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + mcp_send( + &mut stdin, + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), + ); + mcp_recv(&mut stdout, 1); + mcp_send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + mcp_send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list","arguments":{}}})); + let r = mcp_recv(&mut stdout, 2); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("127.0.0.1:1"), "{r}"); + let _ = child.kill(); + let _ = child.wait(); +} + /// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420). #[test] fn batch_fails_inputs_that_share_an_output_name() { diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index ae1837cdd3..5421640a84 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -25,6 +25,7 @@ base64 = "0.22" schemars = "1" getrandom = { workspace = true } cap-std = "4" +clap = { workspace = true } [dev-dependencies] # Synthetic camera raws (#7789). diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs new file mode 100644 index 0000000000..ae67b8fbd2 --- /dev/null +++ b/crates/automation/src/args.rs @@ -0,0 +1,72 @@ +//! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve` +//! flatten them, and the desktop app is to as well (#7071), so each flag is declared once. + +use std::path::PathBuf; + +use clap::Args; +use clap::builder::NonEmptyStringValueParser; + +use crate::{AuthorizedWorkspace, AutomationError}; + +/// The control token, for a server or for a client of one. +#[derive(Args)] +pub struct ControlArgs { + /// The control token (64 hex digits) + #[arg(long, value_name = "64-HEX", value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] + pub control_token: Option, + /// A file holding the control token, used instead of --control-token (a server creates it + /// when missing) + #[arg(long, value_name = "PATH")] + pub control_token_file: Option, +} + +/// The folders automation may touch. Without a root, that direction of access is denied. No +/// variable stands in for them here: one exported for the desktop app must not widen headless +/// `photocraft-cli` access (#1551); the desktop app adds its own. +#[derive(Args)] +pub struct AutomationArgs { + /// The folder automation may read from (none without it) + #[arg(long, value_name = "DIR")] + pub automation_read_root: Option, + /// The folder automation may write to (none without it) + #[arg(long, value_name = "DIR")] + pub automation_write_root: Option, +} + +impl AutomationArgs { + /// The roots, opened as capabilities. + pub fn workspace(&self) -> Result { + AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref()) + } +} + +/// A control port, for clap's `value_parser`: `photocraft-cli serve --port` and `mcp --bridge`, and +/// the desktop app's `--control` once it flattens these (#7071). Spaces around it are ignored, as the +/// desktop app's PHOTOCRAFT_CONTROL_PORT may carry them. +pub fn control_port(s: &str) -> Result { + s.trim().parse().map_err(|_| NotAPort) +} + +/// A [`control_port`] value that isn't a port. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("expected a port, 0-65535")] +pub struct NotAPort; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_port_accepts_valid_numbers() { + assert_eq!(control_port("50494"), Ok(50494)); + assert_eq!(control_port(" 8080 "), Ok(8080)); + assert_eq!(control_port("0"), Ok(0)); + } + + #[test] + fn control_port_rejects_anything_else() { + for bad in ["78787", "-1", "", "x"] { + assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}"); + } + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1e7049d0d..b4691723b9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod args; pub mod bridge; pub mod budgets; pub mod files; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 77edb5e4d0..22bbf17d3b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -315,7 +315,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi `photocraft-automation` provides an MCP server built on the official Rust SDK (`rmcp`). It runs in one of two modes: - **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window. -- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. +- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. The bridge keeps one authenticated TCP connection open, and drops and reconnects before a call once the connection has been idle long enough that the app (which closes idle connections after 30s) may have dropped it. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. Edits are never automatically resent (#1007); a connection that could not be established is reported as "the request was not sent" instead. The MCP server opts its pure-read tools (`ui_inspect`, `doc_inspect`, `command_list`, `jobs_list`, `session_list`, `ui_screenshot`) into one automatic retry on a fresh connection, since a read applies nothing; clients driving `BridgeClient` directly opt in per call with `call_read`. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. The client's call timeout is 55s, strictly below the server's 60s reply deadline, so the bridge's error is the one callers see; commands that can run longer should pass `"wait": false` and poll `jobs.list`. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`: From 18f9f11de4131461098cfd5058d736fb715a7304 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:57:57 +0300 Subject: [PATCH 24/40] feat(cli): environment fallbacks for mcp and serve (#2491, #7071) The desktop app's --control already reads PHOTOCRAFT_CONTROL_PORT, and the CLI read the token variables at run time. Now every fallback is declared with `env =` on its flag (clap's `env` workspace feature, no new crate in Cargo.lock): - --control-token and --control-token-file in ControlArgs fall back to PHOTOCRAFT_CONTROL_TOKEN and PHOTOCRAFT_CONTROL_TOKEN_FILE, as security::token_inputs did at run time. The CLI no longer calls token_inputs, so the variables are read in one place; the desktop app still uses it until step 5. Help names the token's variable but never shows its value (hide_env_values). A token from the environment together with the other flag is now clap's either/or usage error (exit 2) rather than the automation crate's (exit 1). - `serve` takes PHOTOCRAFT_CONTROL_PORT as --port and `mcp` as the bridge port when the flag is absent, as #2495 proposed. The variable must be a port: `mcp` refuses 127.0.0.1:7878 or :7878 from it, as the desktop app's --control would; only --bridge takes host:port. - The automation roots stay flag-only for the CLI: a PHOTOCRAFT_AUTOMATION_*_ROOT exported for the desktop app must not widen headless file access (#1551). - A flag wins, and an empty or blank variable counts as unset (blank_env_is_unset; clap would hand the empty value to the parser), so stdio and headless stay the default. - A bad value or conflict from a variable is a usage error naming the variable instead of the flag it stands in for (name_env_vars). - The port variable changes what the command does, so when it (not the flag) turns on TCP or bridging, the command says so on stderr and names the way back (ArgMatches::value_source tells which). bin() in the tests removes the token and port variables, so an exported one can't change a test. New tests: control_port_env_switches_modes_with_a_notice_and_flags_win (ported from #2495, also checking the bad values) and control_token_env_vars_are_the_flags_fallbacks. Co-authored-by: ahmedhosnypro Co-authored-by: Claude Opus 5.5 --- Cargo.toml | 2 +- apps/photocraft-cli/src/args.rs | 49 +++++++--- apps/photocraft-cli/src/automation.rs | 23 +++-- apps/photocraft-cli/src/lib.rs | 2 +- apps/photocraft-cli/tests/it/cli.rs | 127 +++++++++++++++++++++++++- crates/automation/src/args.rs | 44 +++++++-- crates/automation/src/security.rs | 3 +- 7 files changed, 218 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b1a3417c49..5343ff7477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index c7b8c599dd..17c0e8f529 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -12,8 +12,9 @@ use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use clap::error::ErrorKind; +use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; -use photocraft_automation::args::{AutomationArgs, ControlArgs, control_port}; +use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; use photocraft_io::{DdsCompression, DpxBits, TgaBits}; use serde_json::Value; @@ -202,8 +203,11 @@ pub(crate) struct CommandsArgs { pub(crate) struct McpArgs { /// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means /// 127.0.0.1: - #[arg(long, value_name = "127.0.0.1:PORT", value_parser = bridge)] + #[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)] pub(crate) bridge: Option, + /// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`]. + #[arg(skip)] + pub(crate) bridge_from_env: bool, #[command(flatten)] pub(crate) control: ControlArgs, #[command(flatten)] @@ -219,8 +223,11 @@ pub(crate) struct McpArgs { #[derive(Args)] pub(crate) struct ServeArgs { /// Listen on 127.0.0.1: instead of stdio - #[arg(long, value_name = "PORT", value_parser = control_port)] + #[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)] pub(crate) port: Option, + /// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`]. + #[arg(skip)] + pub(crate) port_from_env: bool, #[command(flatten)] pub(crate) control: ControlArgs, #[command(flatten)] @@ -240,7 +247,7 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, - /// Why a `--bridge` value is refused. + /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, } @@ -288,7 +295,7 @@ impl fmt::Display for Bridge { } } -/// `mcp --bridge`: a port, or `host:port`. +/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port. fn bridge(s: &str) -> Result { match control_port(s) { Ok(port) => Ok(Bridge::Port(port)), @@ -318,9 +325,10 @@ fn steps(m: &ArgMatches) -> Vec { } /// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not -/// be valid Unicode (#1108), text must. +/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable, +/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491). pub(crate) fn parse(args: &[OsString]) -> Result { - let mut cmd = Cli::command(); + let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset)); let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned()); let matches = match cmd.try_get_matches_from_mut(argv) { // Help where a flag's value should be (`--out --help`) is still help: clap reports the @@ -329,25 +337,40 @@ pub(crate) fn parse(args: &[OsString]) -> Result { let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")]; return Err(Cli::command().try_get_matches_from(help).err().unwrap_or(e)); } - matches => matches?, + matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?, }; let mut cli = Cli::from_arg_matches(&matches)?; + let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable); match (&mut cli.command, matches.subcommand()) { (Command::Run(r), Some((_, m))) => r.steps = steps(m), - (Command::Mcp(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), m, "bridge")?, - (Command::Serve(_), Some((name, m))) => check_server(cmd.find_subcommand_mut(name), m, "port")?, + (Command::Mcp(a), Some((name, m))) => { + a.bridge_from_env = from_env(m, "bridge"); + // The variable holds a port, as for the desktop app's --control: only the flag takes + // host:port, which `bridge` lets through. + if a.bridge_from_env + && let Some(Bridge::Addr(v)) = &a.bridge + { + return Err(cmd.error(ErrorKind::ValueValidation, format!("invalid value '{v}' for 'PHOTOCRAFT_CONTROL_PORT': {}", ValueError::Bridge))); + } + check_server(cmd.find_subcommand_mut(name), m, "bridge")?; + } + (Command::Serve(a), Some((name, m))) => { + a.port_from_env = from_env(m, "port"); + check_server(cmd.find_subcommand_mut(name), m, "port")?; + } _ => {} } Ok(cli) } /// Refuse `mcp` and `serve` launch options that would otherwise be silently ignored (#8035): a -/// token without the bridge or port that reads it, and bridge roots, which belong to the desktop -/// process: accepting them here promises authority the bridge cannot set. +/// token flag without the bridge or port that reads it (flag or variable; a token variable alone +/// may be a stale export), and bridge roots, which belong to the desktop process: accepting them +/// here promises authority the bridge cannot set. fn check_server(sub: Option<&mut clap::Command>, m: &ArgMatches, transport: &str) -> Result<(), clap::Error> { let Some(sub) = sub else { return Ok(()) }; for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] { - if m.contains_id(id) && !m.contains_id(transport) { + if m.value_source(id) == Some(ValueSource::CommandLine) && !m.contains_id(transport) { return Err(sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires --{transport}"))); } } diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index 07aab0169a..062f2f2eef 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -37,10 +37,15 @@ pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?))); match a.port { Some(port) => { + if a.port_from_env { + // Name the mode switch: an exported variable changes what this command does, and + // unsetting it (or setting it empty) is the way back to stdio. + let _ = writeln!(err, "photocraft-cli: PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio (unset it to serve stdio)"); + } let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string(); - let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); - let token = security::server_token(supplied.as_deref(), token_file.as_deref())?; - if let Some(path) = &token_file { + let (supplied, token_file) = (a.control.control_token.as_deref(), a.control.control_token_file.as_deref()); + let token = security::server_token(supplied, token_file)?; + if let Some(path) = token_file { let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); } else if supplied.is_none() { let _ = writeln!(err, "photocraft-cli control token: {token}"); @@ -57,11 +62,17 @@ pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { } } -pub(crate) fn mcp(a: &McpArgs) -> Result { +pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result { let server = match &a.bridge { Some(addr) => { - let (supplied, token_file) = security::token_inputs(a.control.control_token.clone(), a.control.control_token_file.clone()); - let token = security::client_token(supplied.as_deref(), token_file.as_deref())?; + if a.bridge_from_env { + // Name the mode switch, as `serve` does: the way back to headless is to unset it. + let _ = writeln!( + err, + "photocraft-cli: PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on {addr} instead of a headless engine (unset it to run headless)" + ); + } + let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?; PhotocraftMcp::bridge(&addr.to_string(), &token)? } None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?), diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index c01c211eeb..25743df2a9 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -48,7 +48,7 @@ where Command::Batch(a) => batch(a, out, err), Command::Droplet(a) => droplet(a, out, err), Command::Commands(a) => commands(a, out), - Command::Mcp(a) => mcp(a), + Command::Mcp(a) => mcp(a, err), Command::Serve(a) => serve(a, err), }; match result { diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index e242bf6084..e34751c4db 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -6,10 +6,17 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; +/// The binary, without the variables `mcp` and `serve` fall back to: a test that wants one sets it. fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_photocraft-cli")) + let mut cmd = Command::new(env!("CARGO_BIN_EXE_photocraft-cli")); + for var in ENV_VARS { + cmd.env_remove(var); + } + cmd } +const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"]; + fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&d); @@ -909,8 +916,6 @@ fn mcp_bridge_takes_a_port_or_an_address() { let token = "a".repeat(64); let mut child = bin() .args(["mcp", "--bridge", "1", "--control-token", token.as_str()]) - .env_remove("PHOTOCRAFT_CONTROL_TOKEN") - .env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -1273,3 +1278,119 @@ fn either_or_flags_are_usage_errors() { assert!(err.contains("--port"), "--port {port}: {err}"); } } + +/// How a run of the binary ended. +struct Exited { + code: Option, + stderr: String, +} + +/// The binary run with `args` and one variable set, stdin at its end. +fn with_var(args: &[&str], var: &str, value: &str) -> Exited { + let o = bin().args(args).env(var, value).stdin(Stdio::null()).output().unwrap(); + Exited { code: o.status.code(), stderr: String::from_utf8_lossy(&o.stderr).into_owned() } +} + +/// A port nothing listens on, for a `serve` to take. +fn free_port() -> u16 { + std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port() +} + +/// `serve` on TCP never exits: read its stderr until it says it is serving (or its stderr closes), +/// then kill it. +fn serve_tcp_stderr(cmd: &mut Command) -> String { + use std::io::Read as _; + let mut child = cmd.stdin(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + // Read on a thread, so a `serve` that stays silent with stderr open fails the test after the + // deadline instead of hanging it. + let mut stderr = child.stderr.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + while let Ok(n) = stderr.read(&mut buf) { + if n == 0 || tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut err = String::new(); + while !err.contains("photocraft-cli serving on") { + match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) { + Ok(chunk) => err.push_str(&String::from_utf8_lossy(&chunk)), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + err +} + +/// #2491: `PHOTOCRAFT_CONTROL_PORT` turns `serve` from stdio into a TCP listener and `mcp` from a +/// headless engine into a bridge, and both say so on stderr. A flag wins, an empty value counts as +/// unset, and a bad value is a usage error naming the variable. +#[test] +fn control_port_env_switches_modes_with_a_notice_and_flags_win() { + let port = free_port(); + let err = serve_tcp_stderr(bin().arg("serve").env("PHOTOCRAFT_CONTROL_PORT", port.to_string())); + assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio")), "{err}"); + assert!(err.contains("unset it to serve stdio") && err.contains("photocraft-cli serving on"), "{err}"); + + // No token is given, so the bridge fails after the notice. + let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()).stderr; + assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on 127.0.0.1:{port}")), "{err}"); + assert!(err.contains("unset it to run headless"), "{err}"); + + // The flag wins, so the variable's bad value is never parsed. + let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(port.to_string()).env("PHOTOCRAFT_CONTROL_PORT", "not-a-port")); + assert!(err.contains("photocraft-cli serving on") && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{err}"); + + // Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and + // `mcp` stays headless. + for empty in ["", " "] { + let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty); + assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}"); + let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", empty).stderr; + assert!(!err.contains("bridging"), "{empty:?}: {err}"); + } + + for (sub, bad) in [ + ("serve", "not-a-port"), + ("serve", "70000"), + ("mcp", "nope"), + ("mcp", "70000"), + // `--bridge` takes host:port, the variable only a port. + ("mcp", "127.0.0.1:7878"), + ("mcp", ":7878"), + ("mcp", "bad:port"), + ] { + let Exited { code, stderr: err } = with_var(&[sub], "PHOTOCRAFT_CONTROL_PORT", bad); + assert_eq!(code, Some(2), "{sub} {bad}: {err}"); + assert!(err.contains(&format!("invalid value '{bad}' for 'PHOTOCRAFT_CONTROL_PORT'")), "{sub} {bad}: {err}"); + } +} + +/// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and +/// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the +/// either/or usage error, naming the variable. Help names the token's variable, never its value. +#[test] +fn control_token_env_vars_are_the_flags_fallbacks() { + let token = "a".repeat(64); + for sub in ["mcp", "serve"] { + let Exited { code, stderr: err } = with_var(&[sub, "--control-token", token.as_str()], "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token"); + assert_eq!(code, Some(2), "{sub}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{sub}: {err}"); + let Exited { code, stderr: err } = with_var(&[sub, "--control-token-file", "token"], "PHOTOCRAFT_CONTROL_TOKEN", &token); + assert_eq!(code, Some(2), "{sub}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{sub}: {err}"); + let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap(); + let out = String::from_utf8_lossy(&o.stdout); + assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}"); + } + // The server takes the variable's token, so it neither generates nor prints one; an empty + // variable counts as unset. + let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", &token)); + assert!(err.contains("photocraft-cli serving on") && !err.contains("control token"), "{err}"); + let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", "")); + assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}"); +} diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs index ae67b8fbd2..9837bc1621 100644 --- a/crates/automation/src/args.rs +++ b/crates/automation/src/args.rs @@ -1,10 +1,16 @@ //! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve` -//! flatten them, and the desktop app is to as well (#7071), so each flag is declared once. +//! flatten them, and the desktop app is to as well (#7071), so each flag and its +//! environment variable are declared once. A flag wins over its variable. +//! +//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset, +//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from. +use std::ffi::OsString; use std::path::PathBuf; -use clap::Args; use clap::builder::NonEmptyStringValueParser; +use clap::error::{ContextKind, ContextValue}; +use clap::{Arg, Args, Command}; use crate::{AuthorizedWorkspace, AutomationError}; @@ -12,11 +18,11 @@ use crate::{AuthorizedWorkspace, AutomationError}; #[derive(Args)] pub struct ControlArgs { /// The control token (64 hex digits) - #[arg(long, value_name = "64-HEX", value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] + #[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] pub control_token: Option, /// A file holding the control token, used instead of --control-token (a server creates it /// when missing) - #[arg(long, value_name = "PATH")] + #[arg(long, value_name = "PATH", env = "PHOTOCRAFT_CONTROL_TOKEN_FILE")] pub control_token_file: Option, } @@ -40,9 +46,8 @@ impl AutomationArgs { } } -/// A control port, for clap's `value_parser`: `photocraft-cli serve --port` and `mcp --bridge`, and -/// the desktop app's `--control` once it flattens these (#7071). Spaces around it are ignored, as the -/// desktop app's PHOTOCRAFT_CONTROL_PORT may carry them. +/// A control port, for clap's `value_parser`: `photocraft --control`, `photocraft-cli serve --port` +/// and PHOTOCRAFT_CONTROL_PORT, which may carry spaces. pub fn control_port(s: &str) -> Result { s.trim().parse().map_err(|_| NotAPort) } @@ -52,6 +57,31 @@ pub fn control_port(s: &str) -> Result { #[error("expected a port, 0-65535")] pub struct NotAPort; +/// For `Command::mut_args`: an empty or blank variable counts as unset, so `PHOTOCRAFT_CONTROL_PORT=` +/// turns a fallback off without `unset` (#2491). clap would pass the empty value to the parser. +pub fn blank_env_is_unset(arg: Arg) -> Arg { + let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty())); + if blank { arg.env(None::<&str>) } else { arg } +} + +/// clap names the flag when a value from its variable is bad or conflicts with another, as if the +/// flag had been given: name the variable instead when the flag isn't among `args` (without the +/// binary name; a first one naming a subcommand selects whose arguments to look in) (#2491). +pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error { + // `--flag value` or `--flag=value`. + let given = |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('=')); + let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd); + for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] { + let Some(ContextValue::String(shown)) = error.get(kind) else { continue }; + let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue }; + let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue }; + if !given(long) { + error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned())); + } + } + error +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 0735511f20..34a9545a92 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -308,7 +308,8 @@ impl Read for AuthDeadlineStream { } } -/// Environment-aware token inputs shared by the desktop app and CLI. +/// Environment-aware token inputs for the desktop app. `photocraft-cli` declares the same variables +/// on its flags instead (`args::ControlArgs`). pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from)); From b4949702e2298cd4cf79a006251197cc3744f8e4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:58:03 +0300 Subject: [PATCH 25/40] docs(cli): describe the mcp and serve environment fallbacks (#7071) Say that PHOTOCRAFT_CONTROL_PORT is the fallback for serve --port and mcp --bridge (with the stderr notice and the empty-is-unset escape), and list the CLI's variables on the book's CLI page, which also says the automation root variables stay the desktop app's. Co-authored-by: Claude Opus 5.5 --- book/src/automation/cli.md | 5 ++++- docs/control-protocol.md | 9 ++++++--- docs/development.md | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 4b2b068372..8970f37b0e 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -66,8 +66,11 @@ Paths supplied to the current CLI are ordinary operating-system paths. The CLI d CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. `mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before -starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port`. Configure +starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` +(or `PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples. +`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. ` --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`. + This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 22bbf17d3b..f1613743af 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -315,7 +315,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi `photocraft-automation` provides an MCP server built on the official Rust SDK (`rmcp`). It runs in one of two modes: - **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window. -- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. +- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. With no `--bridge`, `PHOTOCRAFT_CONTROL_PORT` selects the bridge port (`127.0.0.1:`), and the CLI says so on standard error; with neither (or the variable empty), the MCP runs headless. A value that is not a port is a usage error naming the variable. The bridge keeps one authenticated TCP connection open, and drops and reconnects before a call once the connection has been idle long enough that the app (which closes idle connections after 30s) may have dropped it. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. Edits are never automatically resent (#1007); a connection that could not be established is reported as "the request was not sent" instead. The MCP server opts its pure-read tools (`ui_inspect`, `doc_inspect`, `command_list`, `jobs_list`, `session_list`, `ui_screenshot`) into one automatic retry on a fresh connection, since a read applies nothing; clients driving `BridgeClient` directly opt in per call with `call_read`. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. The client's call timeout is 55s, strictly below the server's 60s reply deadline, so the bridge's error is the one callers see; commands that can run longer should pass `"wait": false` and poll `jobs.list`. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`: @@ -382,12 +382,15 @@ idle shutdown, including during a long command. Clients should reconnect by rest `photocraft-cli serve` if they need a server after the idle deadline. `photocraft-cli serve` keeps one headless engine session (no window, no GPU) and answers the same -JSON-lines envelope on stdio, or on `127.0.0.1:` with `--port ` (loopback only; each +JSON-lines envelope on stdio, or on `127.0.0.1:` with `--port ` or, without the flag, +`PHOTOCRAFT_CONTROL_PORT` (loopback only; each authenticated connection shares the session). TCP uses the same first-frame `auth` exchange and token options as desktop control. Stdio does not require this TCP handshake because access is inherited from the process pipe. It is the fastest way for a script or agent to make many edits: no MCP framing, no app start-up per command. Configure its file access with the same -`--automation-read-root` and `--automation-write-root` flags. Implementation: +`--automation-read-root` and `--automation-write-root` flags. When the +variable, not the flag, turns on TCP, `serve` says so on standard error; an empty value keeps +stdio, and a value that is not a port is a usage error naming the variable. Implementation: `crates/automation/src/rpc.rs`. | Method | Params | diff --git a/docs/development.md b/docs/development.md index 771e24f8b0..81191d8e8d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -145,7 +145,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| -| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | +| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control `; also the port for `photocraft-cli serve --port` and `mcp --bridge` when the flag is absent. Empty is unset | | `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally | | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | From 23548ee92ace83e9737c054a79b43b335203c5c1 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 22 Oct 2026 10:13:59 +0300 Subject: [PATCH 26/40] fix(cli): check the token pair only where a token is read (#7071) With the environment fallbacks, clap's conflicts_with on the token pair made a stale exported PHOTOCRAFT_CONTROL_TOKEN and PHOTOCRAFT_CONTROL_TOKEN_FILE a usage error for every mcp and serve, though headless mcp and stdio serve read no token. As on main, the pair is now refused only by serve on TCP and mcp bridging, still as clap's either/or usage error naming each half as it was given. An automation root on the command line with PHOTOCRAFT_CONTROL_PORT set is a usage error too: the variable would switch mcp to bridging, which drops the roots, with only a note on stderr. Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/args.rs | 21 +++++++++++++- apps/photocraft-cli/tests/it/cli.rs | 43 +++++++++++++++++++++-------- book/src/automation/cli.md | 9 +++--- crates/automation/src/args.rs | 27 ++++++++++++++++-- docs/control-protocol.md | 2 +- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 17c0e8f529..1a1aa4833e 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use clap::error::ErrorKind; use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; -use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, control_port, name_env_vars}; +use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; use photocraft_io::{DdsCompression, DpxBits, TgaBits}; use serde_json::Value; @@ -352,10 +352,29 @@ pub(crate) fn parse(args: &[OsString]) -> Result { { return Err(cmd.error(ErrorKind::ValueValidation, format!("invalid value '{v}' for 'PHOTOCRAFT_CONTROL_PORT': {}", ValueError::Bridge))); } + if let Some(sub) = cmd.find_subcommand_mut(name) { + // Only the bridge reads a token, so a stale exported pair doesn't stop a headless + // run. + if a.bridge.is_some() { + conflict(sub, m, "control_token", "control_token_file")?; + } + // The roots are the headless engine's: the variable switching to the bridge would + // drop them. + if a.bridge_from_env { + conflict(sub, m, "automation_read_root", "bridge")?; + conflict(sub, m, "automation_write_root", "bridge")?; + } + } check_server(cmd.find_subcommand_mut(name), m, "bridge")?; } (Command::Serve(a), Some((name, m))) => { a.port_from_env = from_env(m, "port"); + // Only the TCP server reads a token, so a stale exported pair doesn't stop stdio. + if a.port.is_some() + && let Some(sub) = cmd.find_subcommand_mut(name) + { + conflict(sub, m, "control_token", "control_token_file")?; + } check_server(cmd.find_subcommand_mut(name), m, "port")?; } _ => {} diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index e34751c4db..aad93db025 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1265,11 +1265,11 @@ fn either_or_flags_are_usage_errors() { assert_eq!(code, 2); assert!(err.contains("--params"), "{err}"); let token = "a".repeat(64); - for sub in ["mcp", "serve"] { - let o = bin().args([sub, "--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap(); + for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] { + let o = bin().args(mode).args(["--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap(); let err = String::from_utf8_lossy(&o.stderr); - assert_eq!(o.status.code(), Some(2), "{sub}: {err}"); - assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{sub}: {err}"); + assert_eq!(o.status.code(), Some(2), "{mode:?}: {err}"); + assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{mode:?}: {err}"); } for port in ["70000", "x"] { let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap(); @@ -1347,6 +1347,14 @@ fn control_port_env_switches_modes_with_a_notice_and_flags_win() { // Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and // `mcp` stays headless. + // A root on the command line is the headless engine's: the variable can't switch to a bridge + // that would drop it. + for root in ["--automation-read-root", "--automation-write-root"] { + let Exited { code, stderr: err } = with_var(&["mcp", root, "."], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()); + assert_eq!(code, Some(2), "{root}: {err}"); + assert!(err.contains(root) && err.contains("cannot be used with 'PHOTOCRAFT_CONTROL_PORT'") && !err.contains("bridging"), "{root}: {err}"); + } + for empty in ["", " "] { let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty); assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}"); @@ -1372,17 +1380,30 @@ fn control_port_env_switches_modes_with_a_notice_and_flags_win() { /// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and /// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the -/// either/or usage error, naming the variable. Help names the token's variable, never its value. +/// either/or usage error, naming the variable, when a token is used: a stale exported pair doesn't +/// stop headless `mcp` or stdio `serve`. Help names the token's variable, never its value. #[test] fn control_token_env_vars_are_the_flags_fallbacks() { let token = "a".repeat(64); + for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] { + let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token", token.as_str()][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token"); + assert_eq!(code, Some(2), "{mode:?}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{mode:?}: {err}"); + let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token-file", "token"][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN", &token); + assert_eq!(code, Some(2), "{mode:?}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{mode:?}: {err}"); + } for sub in ["mcp", "serve"] { - let Exited { code, stderr: err } = with_var(&[sub, "--control-token", token.as_str()], "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token"); - assert_eq!(code, Some(2), "{sub}: {err}"); - assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{sub}: {err}"); - let Exited { code, stderr: err } = with_var(&[sub, "--control-token-file", "token"], "PHOTOCRAFT_CONTROL_TOKEN", &token); - assert_eq!(code, Some(2), "{sub}: {err}"); - assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{sub}: {err}"); + let o = bin().arg(sub).env("PHOTOCRAFT_CONTROL_TOKEN", &token).env("PHOTOCRAFT_CONTROL_TOKEN_FILE", "token").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!err.contains("cannot be used with"), "{sub}: {err}"); + // stdio `serve` exits 0 at the end of its input; how headless `mcp` ends there isn't + // pinned, so it only must not be the usage error. + if sub == "serve" { + assert_eq!(o.status.code(), Some(0), "{sub}: {err}"); + } else { + assert_ne!(o.status.code(), Some(2), "{sub}: {err}"); + } let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap(); let out = String::from_utf8_lossy(&o.stdout); assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}"); diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 8970f37b0e..488d3190a2 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -66,11 +66,10 @@ Paths supplied to the current CLI are ordinary operating-system paths. The CLI d CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. `mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before -starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` -(or `PHOTOCRAFT_CONTROL_PORT`, below). Configure -automation roots on the desktop process when using bridge mode; the CLI rejects root flags there. -See the [MCP launch guide](mcp.md) for examples. +starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` (or +`PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using +bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples. -`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. ` --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`. +`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. ` --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`. This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters. diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs index 9837bc1621..236ffbbf3b 100644 --- a/crates/automation/src/args.rs +++ b/crates/automation/src/args.rs @@ -4,13 +4,16 @@ //! //! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset, //! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from. +//! Check the token pair with [`conflict`] only where a token is used, so a stale exported pair +//! doesn't stop a launch that needs none. use std::ffi::OsString; use std::path::PathBuf; use clap::builder::NonEmptyStringValueParser; -use clap::error::{ContextKind, ContextValue}; -use clap::{Arg, Args, Command}; +use clap::error::{ContextKind, ContextValue, ErrorKind}; +use clap::parser::ValueSource; +use clap::{Arg, ArgMatches, Args, Command}; use crate::{AuthorizedWorkspace, AutomationError}; @@ -18,7 +21,7 @@ use crate::{AuthorizedWorkspace, AutomationError}; #[derive(Args)] pub struct ControlArgs { /// The control token (64 hex digits) - #[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new(), conflicts_with = "control_token_file")] + #[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new())] pub control_token: Option, /// A file holding the control token, used instead of --control-token (a server creates it /// when missing) @@ -82,6 +85,24 @@ pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) - error } +/// clap's either/or usage error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was +/// given: the flag, or its variable. For a conflict that depends on the mode, such as the token pair +/// (only a server or bridge reads a token), which clap's `conflicts_with` can't express. +pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> { + if m.value_source(a).is_none() || m.value_source(b).is_none() { + return Ok(()); + } + let shown = |id: &str| { + let arg = cmd.get_arguments().find(|arg| arg.get_id() == id); + match (m.value_source(id), arg.and_then(Arg::get_env)) { + (Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(), + _ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()), + } + }; + let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b)); + Err(cmd.error(ErrorKind::ArgumentConflict, message)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f1613743af..bea5d28b46 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -315,7 +315,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi `photocraft-automation` provides an MCP server built on the official Rust SDK (`rmcp`). It runs in one of two modes: - **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window. -- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. With no `--bridge`, `PHOTOCRAFT_CONTROL_PORT` selects the bridge port (`127.0.0.1:`), and the CLI says so on standard error; with neither (or the variable empty), the MCP runs headless. A value that is not a port is a usage error naming the variable. +- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `): every tool is forwarded to a running `photocraft --control 7878 --control-token-file ` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. With no `--bridge`, `PHOTOCRAFT_CONTROL_PORT` selects the bridge port (`127.0.0.1:`), and the CLI says so on standard error; with neither (or the variable empty), the MCP runs headless. A value that is not a port is a usage error naming the variable. `--automation-read-root` or `--automation-write-root` with the variable set is a usage error, since the bridge would drop the roots. The bridge keeps one authenticated TCP connection open, and drops and reconnects before a call once the connection has been idle long enough that the app (which closes idle connections after 30s) may have dropped it. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. Edits are never automatically resent (#1007); a connection that could not be established is reported as "the request was not sent" instead. The MCP server opts its pure-read tools (`ui_inspect`, `doc_inspect`, `command_list`, `jobs_list`, `session_list`, `ui_screenshot`) into one automatic retry on a fresh connection, since a read applies nothing; clients driving `BridgeClient` directly opt in per call with `call_read`. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. The client's call timeout is 55s, strictly below the server's 60s reply deadline, so the bridge's error is the one callers see; commands that can run longer should pass `"wait": false` and poll `jobs.list`. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`: From 2bba1c13fe36a6d09ff4d4d03e1421e5704afc59 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:43:26 +0300 Subject: [PATCH 27/40] refactor(app): move the launch argument parser into launch_args (#7071) The hand-written argument loop, its LaunchArgs, USAGE and helpers (parse_control_port, usage_exit_code, print_stdout) and their unit tests move unchanged from main.rs into a new launch_args module; only the module doc, the `mod`/`use` lines and pub(crate) visibility are new. main.rs keeps the environment defaults and the exits. This is the step before the clap port, so that one reads as a rewrite of launch_args.rs rather than a large main.rs deletion. Check with `git show --color-moved=dimmed-zebra`. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/launch_args.rs | 298 ++++++++++++++++++++++++++++ apps/photocraft/src/main.rs | 299 +---------------------------- 2 files changed, 301 insertions(+), 296 deletions(-) create mode 100644 apps/photocraft/src/launch_args.rs diff --git a/apps/photocraft/src/launch_args.rs b/apps/photocraft/src/launch_args.rs new file mode 100644 index 0000000000..0afe68315e --- /dev/null +++ b/apps/photocraft/src/launch_args.rs @@ -0,0 +1,298 @@ +//! The desktop app's command line: [`parse_launch_args`] reads the arguments over the +//! environment defaults `main` collects, [`USAGE`] is `photocraft --help`, and every usage error +//! (an unknown option, a missing or bad value) exits with status 2 (issues #701, #6429, #6431). +/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that +/// names the value (issue #701): silently running with no control server leaves a launcher or +/// agent unable to tell a typo from a successful grant. +pub(crate) fn parse_control_port(value: &str, source: &str) -> Result { + value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) +} + +/// Process exit status for the collected usage errors (a bad `--control` / +/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a +/// command-line usage error), never 0. +pub(crate) fn usage_exit_code(errors: &[String]) -> Option { + if errors.is_empty() { None } else { Some(2) } +} + +/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. +pub(crate) const USAGE: &str = "\ +Usage: photocraft [options] [--] [files…] + +Opens PhotoCraft with the given files. + +Options: + -h, --help Print this help and exit + --version Print the version, commit and build date and exit + --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) + --in-window-menus macOS: keep the menus inside the window instead of the menu bar + --new-instance Windows and Linux: start a separate PhotoCraft instead of opening + the files in the one already running + --present-mode How frames reach the display: fifo, autovsync or novsync + --frame-latency Frames queued for the display: 1 to 3 + --stroke-trace + Time each brush stroke's frames + --control Start the localhost JSON-lines control server on this port + --control-token <64-hex> Control server token (default: a new random one, printed) + --control-token-file Read the control server token from this file, or create it + with a new one + --automation-read-root Directory the control server may read files from + --automation-write-root + Directory the control server may write files to + -- Treat every later argument as a file, even one starting with `-` + +A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. +Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, +PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, +PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, +PHOTOCRAFT_CONFIG_DIR. +"; + +/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when +/// stdout is closed or a pipe (`photocraft --help | head -1`). +pub(crate) fn print_stdout(text: &str) { + use std::io::Write as _; + let mut out = std::io::stdout().lock(); + let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); +} + +/// Everything the desktop launcher takes from the command line, on top of the environment +/// defaults handed in to `parse_launch_args`. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct LaunchArgs { + pub(crate) control_port: Option, + pub(crate) control_token: Option, + pub(crate) control_token_file: Option, + pub(crate) automation_read_root: Option, + pub(crate) automation_write_root: Option, + pub(crate) files: Vec, + /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 + /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are + /// reported in a notice once the app is up; a control token is a usage error like a bad port. + pub(crate) unreadable_paths: Vec, + pub(crate) safe_gpu: bool, + pub(crate) new_instance: bool, + /// Options that apply only to the process they're given to; a launch with any keeps its own. + pub(crate) launch_options: Vec<&'static str>, + pub(crate) present_mode_arg: Option, + pub(crate) frame_latency_arg: Option, + pub(crate) stroke_trace_arg: Option, + pub(crate) in_window_menus: bool, + /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. + pub(crate) help: bool, + /// `--version`: print the version and exit 0; parsing stops there. + pub(crate) version: bool, + /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty + /// list fails the launch with exit status 2 (issues #701, #6429, #6431). + pub(crate) errors: Vec, +} + +/// Parse the desktop launch arguments over the environment defaults in `out`. A control or +/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent +/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. +/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. +pub(crate) fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { + let mut args = args; + // After `--`, every argument is a file, even one that starts with `-`. + let mut only_files = false; + while let Some(a) = args.next() { + if only_files { + match a.into_string() { + Ok(s) => out.files.push(s), + Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), + } + continue; + } + match a.to_str() { + Some("--") => only_files = true, + Some("--help" | "-h") => { + out.help = true; + break; + } + Some("--control") => match args.next() { + Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { + Ok(port) => out.control_port = Some(port), + Err(error) => out.errors.push(error), + }, + None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), + }, + Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(token)) => out.control_token = Some(token), + Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), + }, + Some("--control-token-file") => match args.next() { + Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), + }, + Some("--automation-read-root") => { + out.launch_options.push("--automation-read-root"); + match args.next() { + Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), + } + } + Some("--automation-write-root") => { + out.launch_options.push("--automation-write-root"); + match args.next() { + Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), + } + } + Some("--safe-gpu") => { + out.launch_options.push("--safe-gpu"); + out.safe_gpu = true; + } + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + out.launch_options.push(match flag { + "--present-mode" => "--present-mode", + "--frame-latency" => "--frame-latency", + _ => "--stroke-trace", + }); + let flag = flag.to_string(); + match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(v)) => match flag.as_str() { + "--present-mode" => out.present_mode_arg = Some(v), + "--frame-latency" => out.frame_latency_arg = Some(v), + _ => out.stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } + Some("--in-window-menus") => { + out.launch_options.push("--in-window-menus"); + out.in_window_menus = true; + } + Some("--new-instance") => out.new_instance = true, + Some("--version") => { + out.version = true; + break; + } + // Old macOS passes a process serial number when launched from Finder. + Some(s) if s.starts_with("-psn_") => {} + // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window + // and then fail to open the flag as a file (#6431). + Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), + Some(s) => out.files.push(s.to_owned()), + None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), + } + } + out +} + +#[cfg(test)] +mod control_port_tests { + use super::{parse_control_port, usage_exit_code}; + + #[test] + fn control_port_errors_exit_non_zero() { + assert_eq!(usage_exit_code(&[]), None); + let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; + assert_eq!(usage_exit_code(&errors), Some(2)); + } + + #[test] + fn control_port_accepts_valid_numbers() { + assert_eq!(parse_control_port("50494", "--control"), Ok(50494)); + assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080)); + assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0)); + } + + #[test] + fn control_port_rejects_unparseable_values_naming_them() { + // Out of range, a following flag eaten as the value, and an empty value all fail, + // naming the offending value in the message. + let err = parse_control_port("78787", "--control").unwrap_err(); + assert!(err.contains("78787"), "{err}"); + assert!(parse_control_port("--safe-gpu", "--control").is_err()); + assert!(parse_control_port("", "--control").is_err()); + } +} + +#[cfg(test)] +mod launch_args_tests { + use super::{LaunchArgs, parse_launch_args, usage_exit_code}; + use std::ffi::OsString; + + fn parse(values: &[&str]) -> LaunchArgs { + parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // Issue #6429: a missing value must fail the launch, not silently fall back to a + // generated token or to no root. + for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { + let parsed = parse(&[flag]); + assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); + assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); + assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); + assert_eq!(usage_exit_code(&parsed.errors), Some(2)); + } + } + + #[test] + fn missing_value_does_not_clear_an_environment_default() { + // `--automation-read-root` with no value must not drop a root set through the env var. + let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; + let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + } + + #[test] + fn values_are_parsed_and_no_errors_remain() { + let parsed = parse(&[ + "--control", + "50494", + "--control-token", + "abcd", + "--control-token-file", + "/tmp/token", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "mailbox", + "photo.psd", + ]); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + assert_eq!(parsed.control_port, Some(50494)); + assert_eq!(parsed.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu); + assert!(parsed.in_window_menus); + assert!(parsed.new_instance); + assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); + assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); + assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + } + + #[test] + fn version_stops_parsing() { + let parsed = parse(&["--version", "--control"]); + assert!(parsed.version); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + } + + #[test] + fn help_stops_parsing_and_double_dash_ends_options() { + let parsed = parse(&["-h", "--safegpu", "--control"]); + assert!(parsed.help); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + + let parsed = parse(&["--safegpu", "--", "--help", "-x"]); + assert!(!parsed.help); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); + assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index fc896494a4..35baf848fc 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -49,6 +49,7 @@ mod file_dialog_probe; mod gpu_startup; #[cfg(target_os = "linux")] mod icon_theme; +mod launch_args; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] @@ -80,6 +81,8 @@ mod window_frame; use photocraft_engine::Session; use photocraft_ui_egui::PhotocraftApp; +use launch_args::{LaunchArgs, USAGE, parse_control_port, parse_launch_args, print_stdout, usage_exit_code}; + /// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon. const APP_ID: &str = "ai.storyteller.photocraft"; @@ -148,302 +151,6 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde builder } -/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that -/// names the value (issue #701): silently running with no control server leaves a launcher or -/// agent unable to tell a typo from a successful grant. -fn parse_control_port(value: &str, source: &str) -> Result { - value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) -} - -/// Process exit status for the collected usage errors (a bad `--control` / -/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a -/// command-line usage error), never 0. -fn usage_exit_code(errors: &[String]) -> Option { - if errors.is_empty() { None } else { Some(2) } -} - -/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. -const USAGE: &str = "\ -Usage: photocraft [options] [--] [files…] - -Opens PhotoCraft with the given files. - -Options: - -h, --help Print this help and exit - --version Print the version, commit and build date and exit - --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) - --in-window-menus macOS: keep the menus inside the window instead of the menu bar - --new-instance Windows and Linux: start a separate PhotoCraft instead of opening - the files in the one already running - --present-mode How frames reach the display: fifo, autovsync or novsync - --frame-latency Frames queued for the display: 1 to 3 - --stroke-trace - Time each brush stroke's frames - --control Start the localhost JSON-lines control server on this port - --control-token <64-hex> Control server token (default: a new random one, printed) - --control-token-file Read the control server token from this file, or create it - with a new one - --automation-read-root Directory the control server may read files from - --automation-write-root - Directory the control server may write files to - -- Treat every later argument as a file, even one starting with `-` - -A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. -Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, -PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, -PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, -PHOTOCRAFT_CONFIG_DIR. -"; - -/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when -/// stdout is closed or a pipe (`photocraft --help | head -1`). -fn print_stdout(text: &str) { - use std::io::Write as _; - let mut out = std::io::stdout().lock(); - let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); -} - -/// Everything the desktop launcher takes from the command line, on top of the environment -/// defaults handed in to `parse_launch_args`. -#[derive(Debug, Default, PartialEq, Eq)] -struct LaunchArgs { - control_port: Option, - control_token: Option, - control_token_file: Option, - automation_read_root: Option, - automation_write_root: Option, - files: Vec, - /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - /// reported in a notice once the app is up; a control token is a usage error like a bad port. - unreadable_paths: Vec, - safe_gpu: bool, - new_instance: bool, - /// Options that apply only to the process they're given to; a launch with any keeps its own. - launch_options: Vec<&'static str>, - present_mode_arg: Option, - frame_latency_arg: Option, - stroke_trace_arg: Option, - in_window_menus: bool, - /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. - help: bool, - /// `--version`: print the version and exit 0; parsing stops there. - version: bool, - /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty - /// list fails the launch with exit status 2 (issues #701, #6429, #6431). - errors: Vec, -} - -/// Parse the desktop launch arguments over the environment defaults in `out`. A control or -/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent -/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. -/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. -fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { - let mut args = args; - // After `--`, every argument is a file, even one that starts with `-`. - let mut only_files = false; - while let Some(a) = args.next() { - if only_files { - match a.into_string() { - Ok(s) => out.files.push(s), - Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), - } - continue; - } - match a.to_str() { - Some("--") => only_files = true, - Some("--help" | "-h") => { - out.help = true; - break; - } - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => out.control_port = Some(port), - Err(error) => out.errors.push(error), - }, - None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => out.control_token = Some(token), - Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), - }, - Some("--control-token-file") => match args.next() { - Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), - }, - Some("--automation-read-root") => { - out.launch_options.push("--automation-read-root"); - match args.next() { - Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), - } - } - Some("--automation-write-root") => { - out.launch_options.push("--automation-write-root"); - match args.next() { - Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), - } - } - Some("--safe-gpu") => { - out.launch_options.push("--safe-gpu"); - out.safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - out.launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => out.present_mode_arg = Some(v), - "--frame-latency" => out.frame_latency_arg = Some(v), - _ => out.stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - out.launch_options.push("--in-window-menus"); - out.in_window_menus = true; - } - Some("--new-instance") => out.new_instance = true, - Some("--version") => { - out.version = true; - break; - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window - // and then fail to open the flag as a file (#6431). - Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), - Some(s) => out.files.push(s.to_owned()), - None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), - } - } - out -} - -#[cfg(test)] -mod control_port_tests { - use super::{parse_control_port, usage_exit_code}; - - #[test] - fn control_port_errors_exit_non_zero() { - assert_eq!(usage_exit_code(&[]), None); - let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(usage_exit_code(&errors), Some(2)); - } - - #[test] - fn control_port_accepts_valid_numbers() { - assert_eq!(parse_control_port("50494", "--control"), Ok(50494)); - assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080)); - assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0)); - } - - #[test] - fn control_port_rejects_unparseable_values_naming_them() { - // Out of range, a following flag eaten as the value, and an empty value all fail, - // naming the offending value in the message. - let err = parse_control_port("78787", "--control").unwrap_err(); - assert!(err.contains("78787"), "{err}"); - assert!(parse_control_port("--safe-gpu", "--control").is_err()); - assert!(parse_control_port("", "--control").is_err()); - } -} - -#[cfg(test)] -mod launch_args_tests { - use super::{LaunchArgs, parse_launch_args, usage_exit_code}; - use std::ffi::OsString; - - fn parse(values: &[&str]) -> LaunchArgs { - parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) - } - - #[test] - fn flags_without_a_value_are_usage_errors() { - // Issue #6429: a missing value must fail the launch, not silently fall back to a - // generated token or to no root. - for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { - let parsed = parse(&[flag]); - assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); - assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); - assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); - assert_eq!(usage_exit_code(&parsed.errors), Some(2)); - } - } - - #[test] - fn missing_value_does_not_clear_an_environment_default() { - // `--automation-read-root` with no value must not drop a root set through the env var. - let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; - let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); - } - - #[test] - fn values_are_parsed_and_no_errors_remain() { - let parsed = parse(&[ - "--control", - "50494", - "--control-token", - "abcd", - "--control-token-file", - "/tmp/token", - "--automation-read-root", - "/r", - "--automation-write-root", - "/w", - "--safe-gpu", - "--in-window-menus", - "--new-instance", - "--present-mode", - "mailbox", - "photo.psd", - ]); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - assert_eq!(parsed.control_port, Some(50494)); - assert_eq!(parsed.control_token.as_deref(), Some("abcd")); - assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); - assert!(parsed.safe_gpu); - assert!(parsed.in_window_menus); - assert!(parsed.new_instance); - assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); - assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); - assert_eq!(parsed.files, vec!["photo.psd".to_string()]); - } - - #[test] - fn version_stops_parsing() { - let parsed = parse(&["--version", "--control"]); - assert!(parsed.version); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - } - - #[test] - fn help_stops_parsing_and_double_dash_ends_options() { - let parsed = parse(&["-h", "--safegpu", "--control"]); - assert!(parsed.help); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - - let parsed = parse(&["--safegpu", "--", "--help", "-x"]); - assert!(!parsed.help); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); - assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); - assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); - } -} - fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); From 483b01b562f09e22fd29d46a5d3e494b8cebb237 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:57:32 +0300 Subject: [PATCH 28/40] test(app): pin --version and the automation root variables (#7071) Two process tests of today's behaviour that the clap port keeps: `--version` prints `photocraft ` and stops reading the arguments, and PHOTOCRAFT_AUTOMATION_READ_ROOT / _WRITE_ROOT reach the control server's workspace like their flags. The process tests return a Run struct instead of tuples, and control_exit_code gains run_with to set variables. Co-authored-by: Claude Opus 5.5 --- .../photocraft/tests/it/command_line_usage.rs | 35 ++++++++++++------ apps/photocraft/tests/it/control_exit_code.rs | 37 +++++++++++++++---- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/apps/photocraft/tests/it/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs index c4c745ea9a..ecb4316185 100644 --- a/apps/photocraft/tests/it/command_line_usage.rs +++ b/apps/photocraft/tests/it/command_line_usage.rs @@ -7,9 +7,15 @@ //! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the //! other usage errors, so stderr shows whether those arguments were taken as unknown options. -/// Run `photocraft` with `args` in an isolated config dir; return the exit -/// status, stdout and stderr. -fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { +/// How a run of `photocraft` ended. +struct Run { + status: std::process::ExitStatus, + stdout: String, + stderr: String, +} + +/// Run `photocraft` with `args` in an isolated config dir. +fn run_photocraft(args: &[&str]) -> Run { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); @@ -24,13 +30,13 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { .output() .expect("failed to run photocraft binary"); std::fs::remove_dir_all(&dir).ok(); - (output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned()) + Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() } } #[test] fn help_prints_usage_and_exits_0() { for flag in ["--help", "-h"] { - let (status, stdout, stderr) = run_photocraft(&[flag]); + let Run { status, stdout, stderr } = run_photocraft(&[flag]); assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { @@ -42,15 +48,22 @@ fn help_prints_usage_and_exits_0() { #[test] fn help_wins_over_later_arguments() { - let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]); + let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]); assert_eq!(status.code(), Some(0)); assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); } +#[test] +fn version_prints_the_long_version() { + let Run { status, stdout, .. } = run_photocraft(&["--version", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert_eq!(stdout, format!("photocraft {}\n", photocraft_engine::build_info::long_version())); +} + #[test] fn unknown_options_exit_2_naming_them() { for flag in ["--safegpu", "-x", "--help=yes"] { - let (status, stdout, stderr) = run_photocraft(&[flag]); + let Run { status, stdout, stderr } = run_photocraft(&[flag]); assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); @@ -60,7 +73,7 @@ fn unknown_options_exit_2_naming_them() { #[test] fn unknown_option_errors_are_reported_with_control_port_errors() { - let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]); + let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "--safegpu"]); assert_eq!(status.code(), Some(2), "stderr: {stderr}"); assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); @@ -70,7 +83,7 @@ fn unknown_option_errors_are_reported_with_control_port_errors() { fn arguments_after_double_dash_are_files() { // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage // nor is an unknown option; only the bad port is reported. - let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); + let Run { status, stdout, stderr } = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); assert_eq!(status.code(), Some(2), "stderr: {stderr}"); assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); @@ -80,7 +93,7 @@ fn arguments_after_double_dash_are_files() { #[test] fn macos_process_serial_number_is_still_ignored() { // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. - let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]); + let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "-psn_0_12345"]); assert_eq!(status.code(), Some(2), "stderr: {stderr}"); assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); @@ -88,7 +101,7 @@ fn macos_process_serial_number_is_still_ignored() { #[test] fn new_instance_is_a_known_option() { - let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]); + let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "--new-instance"]); assert_eq!(status.code(), Some(2), "stderr: {stderr}"); assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); diff --git a/apps/photocraft/tests/it/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs index 280afac261..3014bc1752 100644 --- a/apps/photocraft/tests/it/control_exit_code.rs +++ b/apps/photocraft/tests/it/control_exit_code.rs @@ -9,10 +9,21 @@ /// workspace check instead of failing on token validation first. const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +/// How a run of `photocraft` ended. +struct Run { + status: std::process::ExitStatus, + stderr: String, +} + /// Run `photocraft` with the given arguments in an isolated config dir and -/// return the exit status and stderr. Each call gets its own config dir (tests run in +/// return how it ended. Each call gets its own config dir (tests run in /// parallel), and no display variables, so a regression fails fast instead of opening a window. -fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) { +fn run_photocraft(args: &[&str]) -> Run { + run_with(args, &[]) +} + +/// [`run_photocraft`], with `vars` set. +fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id())); @@ -29,16 +40,17 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) { .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") + .envs(vars.iter().copied()) .output() .expect("failed to run photocraft binary"); std::fs::remove_dir_all(&dir).ok(); - (output.status, String::from_utf8_lossy(&output.stderr).into_owned()) + Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() } } #[test] fn invalid_control_token_exits_with_status_2() { // `validate_token` rejects a token that is not exactly 64 hex characters. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]); + let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]); assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}"); } @@ -50,7 +62,7 @@ fn both_token_and_token_file_exits_with_status_2() { std::fs::create_dir_all(&dir).unwrap(); let token_file = dir.join("token.txt"); std::fs::write(&token_file, VALID_TOKEN).unwrap(); - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]); + let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]); std::fs::remove_dir_all(&dir).ok(); assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}"); @@ -59,7 +71,8 @@ fn both_token_and_token_file_exits_with_status_2() { #[test] fn missing_automation_read_root_exits_with_status_2() { // `AuthorizedWorkspace::new` fails when the read root does not exist. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]); + let Run { status, stderr } = + run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]); assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}"); } @@ -67,7 +80,17 @@ fn missing_automation_read_root_exits_with_status_2() { #[test] fn missing_automation_write_root_exits_with_status_2() { // `AuthorizedWorkspace::new` fails when the write root does not exist. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]); + let Run { status, stderr } = + run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]); assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}"); } + +#[test] +fn automation_roots_come_from_the_environment_too() { + for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] { + let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]); + assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}"); + assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}"); + } +} From 0d5b7f6b39a6e883f981ca1daf5b3b01c635eb45 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 22 Oct 2026 21:07:30 +0300 Subject: [PATCH 29/40] refactor(app): parse the desktop command line with clap (#7071) Step 5 of the issue: launch_args' hand-written loop, USAGE and LaunchArgs become a clap derive, which flattens photocraft-automation's ControlArgs and AutomationArgs. `photocraft --help` comes from the doc comments; `--version` prints the same `photocraft ` line (build_info::long_version_static), and `-V` now does too. --control uses photocraft_automation::args::control_port instead of a local copy. The cases the loop handled by hand are kept: - An old macOS Finder's -psn_... argument is dropped before clap, unless it comes after `--`, where it is a file name. - The per-process options for the single-instance hand-off come from ArgMatches::value_source, in a fixed order. A PerProcess enum names each (and PHOTOCRAFT_GPU_ADAPTER): id() gives a flag's clap id, Display the flag or the variable, so single_instance's log line reads as before. As since #7242, a flag's variable counts as the flag: one clap reads (PHOTOCRAFT_IN_WINDOW_MENUS when it turns the menus on, an automation root that isn't blank) is counted here, as clap read it; env_launch_options keeps #7242's rules for the three read elsewhere (present mode, frame latency, stroke trace) and returns PerProcess. - A file name that isn't valid Unicode is still reported in a notice instead of being opened (#1108); file_names returns FileNames { openable, unreadable }. - Windows release builds still have no console: help, version and usage errors show only when redirected, as since #6435. clap's Error::exit, like print_stdout, never panics on a closed stream. The variables are now declared on their flags: PHOTOCRAFT_CONTROL_PORT on --control, the token variables through ControlArgs (so security::token_inputs, which nothing else used, is removed), and PHOTOCRAFT_AUTOMATION_*_ROOT added on the desktop's Command only, so photocraft-cli still ignores them (#1551). Behaviour changes, as step 0 of the issue decided: - A bad or missing --present-mode, --frame-latency or --stroke-trace value is a usage error (exit 2) instead of a warning. Their variables stay lenient (logged and ignored); presentation's value checks serve both, and the flags refuse a value with a ValueError. - A repeated flag is a usage error, and a value can't start with `-` unless it is a negative number (write --opt=-value). - clap reports the first usage error, not all of them. On an unknown argument clap resolves the pending option value but drops its error, so `--control nope --safegpu` names `--safegpu`. - An empty or blank variable counts as unset; before, only an empty PHOTOCRAFT_CONTROL_PORT did, and an empty token or root was passed on as given. PHOTOCRAFT_IN_WINDOW_MENUS also reads false, no and off as off (FalseyValueParser). A variable that isn't valid Unicode where text is expected is a usage error instead of being ignored. - With --control (or its variable), a token together with a token file, from flags or variables, is clap's either/or usage error at parse time, before the server's own check. Without it no token is read, so a stale exported pair stops nothing, as before. - With --control given, PHOTOCRAFT_CONTROL_PORT is not read, so a bad value there is no longer reported too. The CPU-compatibility relaunch puts --safe-gpu before the original arguments, so a `--` among them can't turn it into a file name. The launch_args unit tests now check clap's errors instead of the error list. The process tests put a late usage error (--control 0 with the token pair) where they used a bad port, so stderr still shows how the other arguments were taken, and cover the variables. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 2 + apps/photocraft/Cargo.toml | 2 + apps/photocraft/src/launch_args.rs | 528 ++++++++++-------- apps/photocraft/src/main.rs | 95 +--- apps/photocraft/src/presentation.rs | 67 ++- apps/photocraft/src/single_instance.rs | 77 +-- .../photocraft/tests/it/command_line_usage.rs | 119 ++-- apps/photocraft/tests/it/control_exit_code.rs | 5 +- crates/automation/src/args.rs | 4 +- crates/automation/src/security.rs | 10 +- 10 files changed, 475 insertions(+), 434 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c9b56c62f..772c997a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4720,6 +4720,7 @@ dependencies = [ "arboard", "async-io", "chrono", + "clap", "core-graphics2", "dispatch2", "eframe", @@ -4750,6 +4751,7 @@ dependencies = [ "ron", "serde_json", "tempfile", + "thiserror 2.0.21", "ureq", "winit", "winresource", diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5726222bc..d8e7568300 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -34,6 +34,8 @@ egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } chrono = { workspace = true } +clap = { workspace = true } +thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/photocraft/src/launch_args.rs b/apps/photocraft/src/launch_args.rs index 0afe68315e..5e54f84ed8 100644 --- a/apps/photocraft/src/launch_args.rs +++ b/apps/photocraft/src/launch_args.rs @@ -1,256 +1,274 @@ -//! The desktop app's command line: [`parse_launch_args`] reads the arguments over the -//! environment defaults `main` collects, [`USAGE`] is `photocraft --help`, and every usage error -//! (an unknown option, a missing or bad value) exits with status 2 (issues #701, #6429, #6431). -/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that -/// names the value (issue #701): silently running with no control server leaves a launcher or -/// agent unable to tell a typo from a successful grant. -pub(crate) fn parse_control_port(value: &str, source: &str) -> Result { - value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) -} +//! The desktop app's command line, as clap types (#7071): `photocraft --help` comes from these doc +//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage +//! error naming it (exit status 2), so a typo such as `--safegpu` doesn't open the window and then +//! fail to open the flag as a file (#6431); so is a flag without its value, rather than a silent +//! fall back to a generated token or to no root (#6429), a repeated flag, and a bad +//! `--present-mode` or `--frame-latency`. A value never starts with `-` (write `--opt=-value`), +//! except for negative numbers, and a file whose name starts with `-` goes after `--`. +//! +//! The control and automation flags are `photocraft-automation`'s, shared with `photocraft-cli`. +//! Only the desktop app reads the automation roots from `PHOTOCRAFT_AUTOMATION_*_ROOT` (#1551). -/// Process exit status for the collected usage errors (a bad `--control` / -/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a -/// command-line usage error), never 0. -pub(crate) fn usage_exit_code(errors: &[String]) -> Option { - if errors.is_empty() { None } else { Some(2) } -} +use std::ffi::OsString; +use std::fmt; +use std::path::PathBuf; -/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. -pub(crate) const USAGE: &str = "\ -Usage: photocraft [options] [--] [files…] +use clap::builder::FalseyValueParser; +use clap::parser::ValueSource; +use clap::{Command, CommandFactory, FromArgMatches, Parser}; +use eframe::wgpu::PresentMode; +use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; -Opens PhotoCraft with the given files. +use crate::presentation; -Options: - -h, --help Print this help and exit - --version Print the version, commit and build date and exit - --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) - --in-window-menus macOS: keep the menus inside the window instead of the menu bar - --new-instance Windows and Linux: start a separate PhotoCraft instead of opening - the files in the one already running - --present-mode How frames reach the display: fifo, autovsync or novsync - --frame-latency Frames queued for the display: 1 to 3 - --stroke-trace - Time each brush stroke's frames - --control Start the localhost JSON-lines control server on this port - --control-token <64-hex> Control server token (default: a new random one, printed) - --control-token-file Read the control server token from this file, or create it - with a new one - --automation-read-root Directory the control server may read files from - --automation-write-root - Directory the control server may write files to - -- Treat every later argument as a file, even one starting with `-` - -A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. -Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, -PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, -PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, -PHOTOCRAFT_CONFIG_DIR. -"; - -/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when -/// stdout is closed or a pipe (`photocraft --help | head -1`). -pub(crate) fn print_stdout(text: &str) { - use std::io::Write as _; - let mut out = std::io::stdout().lock(); - let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); -} - -/// Everything the desktop launcher takes from the command line, on top of the environment -/// defaults handed in to `parse_launch_args`. -#[derive(Debug, Default, PartialEq, Eq)] +/// Opens PhotoCraft with the given files +#[derive(Parser)] +#[command(name = "photocraft", version = photocraft_engine::build_info::long_version_static(), after_help = ENVIRONMENT)] pub(crate) struct LaunchArgs { - pub(crate) control_port: Option, - pub(crate) control_token: Option, - pub(crate) control_token_file: Option, - pub(crate) automation_read_root: Option, - pub(crate) automation_write_root: Option, - pub(crate) files: Vec, - /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - /// reported in a notice once the app is up; a control token is a usage error like a bad port. - pub(crate) unreadable_paths: Vec, + /// Files to open. On Windows and Linux they open as new tabs in the PhotoCraft already running. + /// Put `--` before a name that starts with `-` + pub(crate) files: Vec, + /// Start with the CPU renderer for this launch (after a graphics driver crash) + #[arg(long)] pub(crate) safe_gpu: bool, - pub(crate) new_instance: bool, - /// Options that apply only to the process they're given to; a launch with any keeps its own. - pub(crate) launch_options: Vec<&'static str>, - pub(crate) present_mode_arg: Option, - pub(crate) frame_latency_arg: Option, - pub(crate) stroke_trace_arg: Option, + /// macOS: keep the menus inside the window instead of the menu bar (the variable: 0, false, no + /// or off is off; empty is unset) + #[arg(long, env = "PHOTOCRAFT_IN_WINDOW_MENUS", value_parser = FalseyValueParser::new())] pub(crate) in_window_menus: bool, - /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. - pub(crate) help: bool, - /// `--version`: print the version and exit 0; parsing stops there. - pub(crate) version: bool, - /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty - /// list fails the launch with exit status 2 (issues #701, #6429, #6431). - pub(crate) errors: Vec, + /// Windows and Linux: start a separate PhotoCraft instead of opening the files in the one + /// already running + #[arg(long)] + pub(crate) new_instance: bool, + /// How frames reach the display: fifo, autovsync or novsync (also immediate or mailbox). + /// Without it, PHOTOCRAFT_PRESENT_MODE + #[arg(long, value_name = "MODE", value_parser = present_mode)] + pub(crate) present_mode: Option, + /// Frames queued for the display, 1 to 3. Without it, PHOTOCRAFT_FRAME_LATENCY + // allow_negative_numbers makes `--frame-latency -1` a bad value rather than an unexpected argument. + #[arg(long, value_name = "1-3", value_parser = frame_latency, allow_negative_numbers = true)] + pub(crate) frame_latency: Option, + /// Time each brush stroke's frames: stderr prints a summary per stroke, a CSV file path also + /// records every frame. Without it, PHOTOCRAFT_STROKE_TRACE + #[arg(long, value_name = "stderr|FILE.csv")] + pub(crate) stroke_trace: Option, + /// Start the localhost JSON-lines control server on this port + #[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)] + pub(crate) control: Option, + #[command(flatten)] + pub(crate) auth: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, + /// The per-process options given on the command line, set by [`parse`]. + #[arg(skip)] + pub(crate) launch_options: Vec, } -/// Parse the desktop launch arguments over the environment defaults in `out`. A control or -/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent -/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. -/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. -pub(crate) fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { - let mut args = args; - // After `--`, every argument is a file, even one that starts with `-`. - let mut only_files = false; - while let Some(a) = args.next() { - if only_files { - match a.into_string() { - Ok(s) => out.files.push(s), - Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), - } - continue; - } - match a.to_str() { - Some("--") => only_files = true, - Some("--help" | "-h") => { - out.help = true; - break; - } - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => out.control_port = Some(port), - Err(error) => out.errors.push(error), - }, - None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => out.control_token = Some(token), - Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), - }, - Some("--control-token-file") => match args.next() { - Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), - }, - Some("--automation-read-root") => { - out.launch_options.push("--automation-read-root"); - match args.next() { - Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), - } - } - Some("--automation-write-root") => { - out.launch_options.push("--automation-write-root"); - match args.next() { - Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), - } - } - Some("--safe-gpu") => { - out.launch_options.push("--safe-gpu"); - out.safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - out.launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => out.present_mode_arg = Some(v), - "--frame-latency" => out.frame_latency_arg = Some(v), - _ => out.stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - out.launch_options.push("--in-window-menus"); - out.in_window_menus = true; - } - Some("--new-instance") => out.new_instance = true, - Some("--version") => { - out.version = true; - break; - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window - // and then fail to open the flag as a file (#6431). - Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), - Some(s) => out.files.push(s.to_owned()), - None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), - } +/// The variables that aren't a flag's fallback, for the end of `--help`. +const ENVIRONMENT: &str = "\ +Environment: PHOTOCRAFT_NEW_INSTANCE (anything but 0 or empty: as --new-instance), PHOTOCRAFT_CONFIG_DIR (the settings folder)"; + +/// An option that applies only to the process it's given to: a launch with any of them keeps its +/// own process instead of handing its files to the running one (`single_instance`). Shown as the +/// flag, or the variable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PerProcess { + AutomationReadRoot, + AutomationWriteRoot, + SafeGpu, + InWindowMenus, + PresentMode, + FrameLatency, + StrokeTrace, + /// PHOTOCRAFT_GPU_ADAPTER, when startup will act on it (`gpu_startup`). Not a flag. + GpuAdapterEnv, +} + +impl PerProcess { + /// The flags, in the order a log line lists them. A flag's variable asks for the same thing, so + /// it counts too (#7242); the variables that aren't a flag's fallback are counted by + /// `single_instance::env_launch_options`. + pub(crate) const FLAGS: [PerProcess; 7] = + [Self::AutomationReadRoot, Self::AutomationWriteRoot, Self::SafeGpu, Self::InWindowMenus, Self::PresentMode, Self::FrameLatency, Self::StrokeTrace]; + + /// The flag's clap argument id. + fn id(self) -> Option<&'static str> { + Some(match self { + Self::AutomationReadRoot => "automation_read_root", + Self::AutomationWriteRoot => "automation_write_root", + Self::SafeGpu => "safe_gpu", + Self::InWindowMenus => "in_window_menus", + Self::PresentMode => "present_mode", + Self::FrameLatency => "frame_latency", + Self::StrokeTrace => "stroke_trace", + Self::GpuAdapterEnv => return None, + }) } - out +} + +impl fmt::Display for PerProcess { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::AutomationReadRoot => "--automation-read-root", + Self::AutomationWriteRoot => "--automation-write-root", + Self::SafeGpu => "--safe-gpu", + Self::InWindowMenus => "--in-window-menus", + Self::PresentMode => "--present-mode", + Self::FrameLatency => "--frame-latency", + Self::StrokeTrace => "--stroke-trace", + Self::GpuAdapterEnv => "PHOTOCRAFT_GPU_ADAPTER", + }) + } +} + +/// The files to open, as [`LaunchArgs::file_names`] sorts them. +#[derive(Debug, PartialEq)] +pub(crate) struct FileNames { + pub(crate) openable: Vec, + /// Names that are not valid Unicode, shown lossily: reported, not opened. + pub(crate) unreadable: Vec, +} + +impl LaunchArgs { + /// The files to open, and those whose names are not valid Unicode (#1108): a file manager can + /// hand over a Latin-1 name on Linux. Those are reported in a notice once the app is up. + pub(crate) fn file_names(&self) -> FileNames { + let mut names = FileNames { openable: Vec::new(), unreadable: Vec::new() }; + for f in &self.files { + match f.to_str() { + Some(s) => names.openable.push(s.to_owned()), + None => names.unreadable.push(f.to_string_lossy().into_owned()), + } + } + names + } +} + +/// The command line, with the desktop app's variables for the automation roots. An empty or blank +/// variable counts as unset. +pub(crate) fn command() -> Command { + LaunchArgs::command() + .mut_arg("automation_read_root", |a| a.env("PHOTOCRAFT_AUTOMATION_READ_ROOT")) + .mut_arg("automation_write_root", |a| a.env("PHOTOCRAFT_AUTOMATION_WRITE_ROOT")) + .mut_args(blank_env_is_unset) +} + +/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS +/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless +/// it comes after `--`. A bad value from a variable is a usage error naming the variable. +pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator) -> Result { + let mut options = true; + let args: Vec = args + .into_iter() + .filter(|a| { + options &= a != "--"; + !(options && a.to_str().is_some_and(|s| s.starts_with("-psn_"))) + }) + .collect(); + let argv = std::iter::once(OsString::from("photocraft")).chain(args.iter().cloned()); + let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?; + let mut launch = LaunchArgs::from_arg_matches(&matches)?; + // Only the control server reads a token, so a stale exported pair doesn't stop a launch. + if launch.control.is_some() { + conflict(&mut cmd, &matches, "control_token", "control_token_file")?; + } + // `PHOTOCRAFT_IN_WINDOW_MENUS=0` (or off, false, no) is a variable that turns the menus off. + let menus = launch.in_window_menus; + let given = |p: &PerProcess| match p.id().and_then(|id| matches.value_source(id)) { + Some(ValueSource::CommandLine) => true, + Some(ValueSource::EnvVariable) => *p != PerProcess::InWindowMenus || menus, + _ => false, + }; + launch.launch_options = PerProcess::FLAGS.into_iter().filter(given).collect(); + Ok(launch) +} + +/// Why a value was refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +enum ValueError { + #[error("expected fifo, autovsync or novsync (or immediate, mailbox)")] + PresentMode, + #[error("expected 1, 2 or 3")] + FrameLatency, +} + +fn present_mode(s: &str) -> Result { + presentation::present_mode(s).ok_or(ValueError::PresentMode) +} + +fn frame_latency(s: &str) -> Result { + presentation::frame_latency(s).ok_or(ValueError::FrameLatency) } #[cfg(test)] -mod control_port_tests { - use super::{parse_control_port, usage_exit_code}; +mod tests { + use super::*; + use clap::error::ErrorKind; - #[test] - fn control_port_errors_exit_non_zero() { - assert_eq!(usage_exit_code(&[]), None); - let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(usage_exit_code(&errors), Some(2)); + /// Parse without the variables, so one exported where the tests run can't change them (the + /// process tests in `tests/command_line_usage.rs` cover the variables). + fn parse_args(args: &[&str]) -> Result { + parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from)) } - #[test] - fn control_port_accepts_valid_numbers() { - assert_eq!(parse_control_port("50494", "--control"), Ok(50494)); - assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080)); - assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0)); + fn usage_error(args: &[&str]) -> String { + let Err(e) = parse_args(args) else { panic!("{args:?} parsed") }; + assert_eq!(e.exit_code(), 2, "{args:?}: {e}"); + e.to_string() } #[test] fn control_port_rejects_unparseable_values_naming_them() { - // Out of range, a following flag eaten as the value, and an empty value all fail, - // naming the offending value in the message. - let err = parse_control_port("78787", "--control").unwrap_err(); - assert!(err.contains("78787"), "{err}"); - assert!(parse_control_port("--safe-gpu", "--control").is_err()); - assert!(parse_control_port("", "--control").is_err()); - } -} - -#[cfg(test)] -mod launch_args_tests { - use super::{LaunchArgs, parse_launch_args, usage_exit_code}; - use std::ffi::OsString; - - fn parse(values: &[&str]) -> LaunchArgs { - parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + // Out of range, negative and empty values fail, naming the value (#701); a following + // flag is never taken as the value. + assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control '")); + assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1'")); + assert!(usage_error(&["--control="]).contains("invalid value '' for '--control '")); + assert!(usage_error(&["--control", "--safe-gpu"]).contains("a value is required for '--control '")); } #[test] fn flags_without_a_value_are_usage_errors() { - // Issue #6429: a missing value must fail the launch, not silently fall back to a - // generated token or to no root. - for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { - let parsed = parse(&[flag]); - assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); - assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); - assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); - assert_eq!(usage_exit_code(&parsed.errors), Some(2)); + // #6429: a missing value fails the launch, rather than falling back to a generated token + // or to no root. The tuning flags too, now (#7071). + for flag in [ + "--control", + "--control-token", + "--control-token-file", + "--automation-read-root", + "--automation-write-root", + "--present-mode", + "--frame-latency", + "--stroke-trace", + ] { + let err = usage_error(&[flag]); + assert!(err.contains(&format!("a value is required for '{flag} <")), "{flag}: {err}"); } } #[test] - fn missing_value_does_not_clear_an_environment_default() { - // `--automation-read-root` with no value must not drop a root set through the env var. - let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; - let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + fn bad_tuning_values_and_repeats_are_usage_errors() { + assert!(usage_error(&["--present-mode", "fast"]).contains("expected fifo, autovsync or novsync")); + for latency in ["0", "4", "-1", "one"] { + let err = usage_error(&["--frame-latency", latency]); + assert!(err.contains(&format!("invalid value '{latency}' for '--frame-latency <1-3>'")), "{err}"); + } + assert!(usage_error(&["--safe-gpu", "--safe-gpu"]).contains("cannot be used multiple times")); + assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times")); + // A token and a token file are either/or for the control server, and ignored without it. + let pair = ["--control-token", "abcd", "--control-token-file", "/tmp/token"]; + assert!(usage_error(&[&["--control", "1"][..], &pair[..]].concat()).contains("cannot be used with")); + assert!(parse_args(&pair).is_ok()); } #[test] - fn values_are_parsed_and_no_errors_remain() { - let parsed = parse(&[ + fn values_are_parsed_and_per_process_options_recorded() { + let parsed = parse_args(&[ + "--stroke-trace", + "stderr", "--control", "50494", "--control-token", "abcd", - "--control-token-file", - "/tmp/token", "--automation-read-root", "/r", "--automation-write-root", @@ -259,40 +277,64 @@ mod launch_args_tests { "--in-window-menus", "--new-instance", "--present-mode", - "mailbox", + "Mailbox", + "--frame-latency", + "1", "photo.psd", - ]); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - assert_eq!(parsed.control_port, Some(50494)); - assert_eq!(parsed.control_token.as_deref(), Some("abcd")); - assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); - assert!(parsed.safe_gpu); - assert!(parsed.in_window_menus); - assert!(parsed.new_instance); - assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); - assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); - assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + ]) + .unwrap(); + assert_eq!(parsed.control, Some(50494)); + assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.auth.control_token_file, None); + assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance); + assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync)); + assert_eq!(parsed.frame_latency, Some(1)); + assert_eq!(parsed.stroke_trace.as_deref(), Some("stderr")); + // In a fixed order: `single_instance` only lists them in a log line. + assert_eq!(parsed.launch_options, PerProcess::FLAGS); + assert_eq!(parsed.file_names(), FileNames { openable: vec!["photo.psd".to_string()], unreadable: vec![] }); + + let parsed = parse_args(&["--control-token-file", "/tmp/token", "--new-instance"]).unwrap(); + assert_eq!(parsed.auth.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert!(parsed.launch_options.is_empty() && !parsed.in_window_menus, "{:?}", parsed.launch_options); } #[test] fn version_stops_parsing() { - let parsed = parse(&["--version", "--control"]); - assert!(parsed.version); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + let e = parse_args(&["--version", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0)); + assert_eq!(e.to_string(), format!("photocraft {}\n", photocraft_engine::build_info::long_version())); } #[test] fn help_stops_parsing_and_double_dash_ends_options() { - let parsed = parse(&["-h", "--safegpu", "--control"]); - assert!(parsed.help); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + let e = parse_args(&["-h", "--safegpu", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0)); - let parsed = parse(&["--safegpu", "--", "--help", "-x"]); - assert!(!parsed.help); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); - assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); - assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); + assert!(usage_error(&["--safegpu", "--", "--help"]).contains("unexpected argument '--safegpu'")); + let parsed = parse_args(&["a.psd", "--", "--help", "-x"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["a.psd", "--help", "-x"]); + } + + #[test] + fn a_finder_process_serial_number_is_dropped() { + let parsed = parse_args(&["-psn_0_12345", "a.psd"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["a.psd"]); + let parsed = parse_args(&["--", "-psn_x.psd"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["-psn_x.psd"]); + } + + /// #1108: a name that isn't valid Unicode is reported, not opened; a token must be text. + #[cfg(unix)] + #[test] + fn non_unicode_file_names_are_reported_and_tokens_rejected() { + use std::os::unix::ffi::OsStringExt as _; + let latin1 = OsString::from_vec(b"caf\xe9.psd".to_vec()); + let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap(); + assert_eq!(parsed.file_names(), FileNames { openable: vec![], unreadable: vec!["caf\u{fffd}.psd".to_string()] }); + let e = parse(command().mut_args(|a| a.env(None::<&str>)), [OsString::from("--control-token"), latin1]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::InvalidUtf8, 2)); } } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 35baf848fc..7fbf765129 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -1,14 +1,7 @@ //! PhotoCraft desktop app. //! -//! Usage: `photocraft [--control ] [--control-token <64-hex> | -//! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] -//! [--frame-latency ] [--stroke-trace ] [--new-instance] [--] [files…]` -//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version). -//! -//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo -//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file -//! whose name starts with `-` goes after `--`. +//! Usage: `photocraft [options] [--] [files…]`; `photocraft --help` lists the options, which are +//! declared, with what makes a usage error (exit status 2), in `launch_args`. //! //! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new //! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any @@ -31,7 +24,8 @@ //! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods. // Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer -// doesn't open a console window. (`--version` output then only shows when redirected.) +// doesn't open a console window. (`--help`, `--version` and usage errors then only show when +// redirected.) #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -81,8 +75,6 @@ mod window_frame; use photocraft_engine::Session; use photocraft_ui_egui::PhotocraftApp; -use launch_args::{LaunchArgs, USAGE, parse_control_port, parse_launch_args, print_stdout, usage_exit_code}; - /// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon. const APP_ID: &str = "ai.storyteller.photocraft"; @@ -157,58 +149,24 @@ fn main() -> eframe::Result { crash_guard::install_hook(); // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); - let mut launch = LaunchArgs { - automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), - automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), - in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), - ..Default::default() - }; - if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { - match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { - Ok(port) => launch.control_port = Some(port), - Err(error) => launch.errors.push(error), - } - } - launch = parse_launch_args(std::env::args_os().skip(1), launch); - if launch.help { - print_stdout(USAGE); - return Ok(()); - } - if launch.version { - print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); - return Ok(()); - } - - // A malformed control port must not silently drop the control server (issue #701), a control - // or automation flag without its value must not silently fall back to a generated token or - // to no root (issue #6429), and an unknown option must not open the window (#6431): name the - // bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit - // status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = usage_exit_code(&launch.errors) { - for error in &launch.errors { - eprintln!("photocraft: {error}"); - } - eprintln!("Run `photocraft --help` for usage."); - std::process::exit(code); - } - let LaunchArgs { - control_port, - control_token, - control_token_file, - automation_read_root, - automation_write_root, - files, - unreadable_paths, + // `--help` and `--version` print to standard output and exit 0. A usage error (an unknown + // option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431) + // names the argument on standard error and exits 2, so a launcher sees the failure. Neither + // panics when the stream is closed or a pipe (`photocraft --help | head -1`). + let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit()); + let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names(); + let launch_args::LaunchArgs { + files: _, safe_gpu, - new_instance, - mut launch_options, - present_mode_arg, - frame_latency_arg, - stroke_trace_arg, in_window_menus, - help: _, - version: _, - errors: _, + new_instance, + present_mode, + frame_latency, + stroke_trace, + control: control_port, + auth, + automation, + mut launch_options, } = launch; // The log file lives under the settings directory; opened after the arguments, so `--version` @@ -224,7 +182,7 @@ fn main() -> eframe::Result { // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1; if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) { - launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); + launch_options.push(launch_args::PerProcess::GpuAdapterEnv); } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs // become tabs in its window. Only with the per-user settings folder (a portable copy's may be @@ -305,7 +263,7 @@ fn main() -> eframe::Result { } let control = if let Some(port) = control_port { - let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file); + let (supplied, token_file) = (auth.control_token, auth.control_token_file); let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { Ok(token) => token, Err(e) => { @@ -320,7 +278,7 @@ fn main() -> eframe::Result { } else { eprintln!("photocraft: using supplied control token"); } - let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) { + let workspace = match automation.workspace() { Ok(workspace) => workspace, Err(error) => { eprintln!("photocraft: cannot configure automation workspace: {error}"); @@ -409,7 +367,7 @@ fn main() -> eframe::Result { options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync; } // Testers' present mode and frame latency overrides (#1343), after the preference. - let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref()); + let present = presentation::Overrides::from_args_or_env(present_mode, frame_latency); options.wgpu_options.surface = present.apply(options.wgpu_options.surface); let present_desc = presentation::describe(&options.wgpu_options.surface, &present); // Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out. @@ -487,7 +445,7 @@ fn main() -> eframe::Result { let info = &mut app.perf.gpu_info; info.preference = pref.name().to_string(); info.present = present_desc.clone(); - if let Some(v) = &stroke_trace_arg { + if let Some(v) = &stroke_trace { app.stroke_trace.set(v); } info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() }; @@ -645,9 +603,10 @@ fn main() -> eframe::Result { s.finish(); } if let Ok(exe) = std::env::current_exe() { + // `--safe-gpu` first: after a `--`, it would be a file name. let launched = std::process::Command::new(exe) - .args(std::env::args_os().skip(1)) .arg("--safe-gpu") + .args(std::env::args_os().skip(1)) .env_remove("WGPU_BACKEND") .env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason) .spawn(); diff --git a/apps/photocraft/src/presentation.rs b/apps/photocraft/src/presentation.rs index c6f7e4196a..ded323872b 100644 --- a/apps/photocraft/src/presentation.rs +++ b/apps/photocraft/src/presentation.rs @@ -2,8 +2,10 @@ //! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`, //! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo //! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may -//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a -//! warning in the log, never a failed start; a mode the driver may not offer is never requested. +//! queue). Unset, the platform default applies. A variable's value that isn't one of these is ignored +//! with a warning in the log, never a failed start; the `--present-mode` and `--frame-latency` flags +//! take the same values, and a bad one is a usage error (#7071). A mode the driver may not offer is +//! never requested. //! The result is logged at start and shown in the stroke trace. use eframe::egui_wgpu::SurfaceConfig; @@ -16,45 +18,54 @@ pub struct Overrides { pub latency: Option, } +/// A `--present-mode` / `PHOTOCRAFT_PRESENT_MODE` value, or `None` when it isn't one. +pub fn present_mode(v: &str) -> Option { + match v.trim().to_ascii_lowercase().as_str() { + // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or + // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without + // vsync, wgpu picks Immediate, else Mailbox, else Fifo. + "fifo" | "vsync" => Some(PresentMode::Fifo), + "autovsync" => Some(PresentMode::AutoVsync), + "novsync" | "immediate" | "mailbox" | "autonovsync" => Some(PresentMode::AutoNoVsync), + _ => None, + } +} + +/// A `--frame-latency` / `PHOTOCRAFT_FRAME_LATENCY` value (1 to 3), or `None` when it isn't one. +pub fn frame_latency(v: &str) -> Option { + v.trim().parse().ok().filter(|n| (1..=3).contains(n)) +} + /// Parse the two variables' values. Returns the overrides and a warning per rejected value. pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec) { let mut warnings = Vec::new(); let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { - let m = match v.to_ascii_lowercase().as_str() { - // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or - // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without - // vsync, wgpu picks Immediate, else Mailbox, else Fifo. - "fifo" | "vsync" => PresentMode::Fifo, - "autovsync" => PresentMode::AutoVsync, - "novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync, - _ => { - warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); - return None; - } - }; - Some(m) - }); - let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::() { - Ok(n @ 1..=3) => Some(n), - _ => { - warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); - None + let m = present_mode(v); + if m.is_none() { + warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); } + m + }); + let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { + let n = frame_latency(v); + if n.is_none() { + warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); + } + n }); (Overrides { mode, latency }, warnings) } impl Overrides { - /// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` / - /// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged. - pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self { - let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok(); - let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok(); - let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref())); + /// `--present-mode` / `--frame-latency` when given (checked by the command line), else + /// `PHOTOCRAFT_PRESENT_MODE` / `PHOTOCRAFT_FRAME_LATENCY`, whose rejected values are logged. + pub fn from_args_or_env(mode: Option, latency: Option) -> Self { + let env = |given: bool, name: &str| if given { None } else { std::env::var(name).ok() }; + let (o, warnings) = parse(env(mode.is_some(), "PHOTOCRAFT_PRESENT_MODE").as_deref(), env(latency.is_some(), "PHOTOCRAFT_FRAME_LATENCY").as_deref()); for w in warnings { log::warn!("{w}"); } - o + Self { mode: mode.or(o.mode), latency: latency.or(o.latency) } } /// `surface` with the overrides applied. diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index 953da20745..b193cade68 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -41,6 +41,8 @@ use photocraft_automation::security::{LineRead, generate_token, read_bounded_lin use photocraft_ui_egui::{OsEvent, OsEventsFn}; use serde_json::{Value, json}; +use crate::launch_args::PerProcess; + const LOCK_FILE: &str = "instance.lock"; const INFO_FILE: &str = "instance.json"; /// The Unix socket a sandboxed primary listens on, in the app's runtime directory. @@ -131,20 +133,19 @@ pub struct LaunchContext<'a> { pub unreadable_paths: bool, /// Options that only apply to the process they're given to (`--safe-gpu`, …), including the /// ones this launch's environment sets (see [`env_launch_options`]). - pub launch_options: &'a [&'static str], + pub launch_options: &'a [PerProcess], } -/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for +/// Variables documented as the equivalent of a per-launch flag, each with the option it stands for /// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for /// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this -/// process alone. -const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [ - ("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on), - ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on), - ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on), - ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on), - ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set), - ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set), +/// process alone. These are read where they take effect, not by the command line; the variables +/// that are a flag's fallback (`PHOTOCRAFT_IN_WINDOW_MENUS`, the automation roots) are counted by +/// [`crate::launch_args::parse`], as clap reads them. +const ENV_LAUNCH_OPTIONS: [(&str, PerProcess, ValueRule); 3] = [ + ("PHOTOCRAFT_PRESENT_MODE", PerProcess::PresentMode, present_mode_is_on), + ("PHOTOCRAFT_FRAME_LATENCY", PerProcess::FrameLatency, frame_latency_is_on), + ("PHOTOCRAFT_STROKE_TRACE", PerProcess::StrokeTrace, stroke_trace_is_on), ]; /// Whether a variable's value asks this launch for its own option. Each rule matches how that @@ -169,29 +170,15 @@ fn stroke_trace_is_on(value: &str) -> bool { !matches!(value.trim(), "" | "0" | "off" | "false") } -/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is -/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does -/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too. -fn flag_is_on(value: &str) -> bool { - !value.is_empty() && value != "0" -} - -/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty -/// root is read as no root unless `--control` is given, and that flag keeps its own process already. -fn path_is_set(value: &str) -> bool { - !value.is_empty() -} - -/// The per-process options this launch's environment sets, named as the flags they stand for. A -/// launch that sets only variables would otherwise hand its files to the running instance, which -/// never sees the override, and exit. -pub fn env_launch_options() -> Vec<&'static str> { +/// The per-process options this launch's environment sets. A launch that sets only variables would +/// otherwise hand its files to the running instance, which never sees the override, and exit. +pub fn env_launch_options() -> Vec { env_launch_options_from(|name| std::env::var_os(name)) } /// [`env_launch_options`] with the reader given, so the rule is testable without the environment. -fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec<&'static str> { - ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect() +fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec { + ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, option, _)| *option).collect() } /// Why this launch runs as its own instance, or `None` to take part in single-instance. @@ -209,7 +196,8 @@ pub fn skip_reason(c: &LaunchContext) -> Option { return Some("some paths aren't valid Unicode".into()); } if !c.launch_options.is_empty() { - return Some(format!("{} only applies to a new process", c.launch_options.join(", "))); + let options: Vec = c.launch_options.iter().map(ToString::to_string).collect(); + return Some(format!("{} only applies to a new process", options.join(", "))); } None } @@ -898,8 +886,8 @@ mod tests { assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None); assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some()); assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some()); - let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); - assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); + let reason = skip_reason(&LaunchContext { launch_options: &[PerProcess::SafeGpu, PerProcess::GpuAdapterEnv], ..base }); + assert_eq!(reason.as_deref(), Some("--safe-gpu, PHOTOCRAFT_GPU_ADAPTER only applies to a new process")); } /// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime @@ -983,30 +971,25 @@ mod tests { #[test] fn an_environment_override_counts_as_a_process_option() { let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into())); - assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]); + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]); assert_eq!( - with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]), - vec!["--stroke-trace", "--automation-write-root"] + with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_FRAME_LATENCY", "2")]), + vec![PerProcess::FrameLatency, PerProcess::StrokeTrace] ); // Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way - // it is read where it takes effect, not by one rule shared across all six. + // it is read where it takes effect, not by one rule shared across all three. assert!(with(&[]).is_empty()); - assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); + assert!(with(&[("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); // The trace is off at `off` and `false` as well, and a value the presentation parser rejects // is ignored there, so neither keeps a process of its own here. assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty()); - assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty()); - // Spaces are part of the value only where the reader keeps them: the trace and the two - // presentation values are trimmed where they take effect, the menu flag and a path are not. - assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]); + // The trace and the two presentation values are trimmed where they take effect. assert_eq!( - with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]), - vec!["--in-window-menus", "--automation-write-root"] + with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), + vec![PerProcess::PresentMode, PerProcess::StrokeTrace] ); - // A whitespace-only menu value is not empty, so it enables the menus there and here too. - assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]); - // `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too. - assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]); + // The menus and the automation roots are flags' fallbacks: `launch_args::parse` counts them. + assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "1"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]).is_empty()); let base = LaunchContext { per_user_settings: true, new_instance_flag: false, diff --git a/apps/photocraft/tests/it/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs index ecb4316185..d9afc64194 100644 --- a/apps/photocraft/tests/it/command_line_usage.rs +++ b/apps/photocraft/tests/it/command_line_usage.rs @@ -4,8 +4,22 @@ //! //! These run the real binary, and every case must exit before the window opens: Windows and //! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. -//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the -//! other usage errors, so stderr shows whether those arguments were taken as unknown options. +//! Cases whose arguments are valid add a usage error before them: `--control-token` with +//! `--control-token-file` is only checked once every argument is read (and only with `--control`), +//! so stderr shows how the other arguments were taken. + +/// The variables the command line reads: a test that wants one sets it. +const VARS: [&str; 6] = [ + "PHOTOCRAFT_CONTROL_PORT", + "PHOTOCRAFT_CONTROL_TOKEN", + "PHOTOCRAFT_CONTROL_TOKEN_FILE", + "PHOTOCRAFT_AUTOMATION_READ_ROOT", + "PHOTOCRAFT_AUTOMATION_WRITE_ROOT", + "PHOTOCRAFT_IN_WINDOW_MENUS", +]; + +/// A usage error found only after every argument is read. +const LATE_ERROR: [&str; 6] = ["--control", "0", "--control-token", "abcd", "--control-token-file", "token"]; /// How a run of `photocraft` ended. struct Run { @@ -14,16 +28,20 @@ struct Run { stderr: String, } -/// Run `photocraft` with `args` in an isolated config dir. -fn run_photocraft(args: &[&str]) -> Run { +/// Run `photocraft` with `args` and `vars` in an isolated config dir. +fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")); + for var in VARS { + cmd.env_remove(var); + } + let output = cmd .args(args) + .envs(vars.iter().copied()) .env("PHOTOCRAFT_CONFIG_DIR", &dir) - .env_remove("PHOTOCRAFT_CONTROL_PORT") .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") @@ -33,13 +51,26 @@ fn run_photocraft(args: &[&str]) -> Run { Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() } } +fn run_photocraft(args: &[&str]) -> Run { + run_with(args, &[]) +} + +/// [`LATE_ERROR`] then `args`: exit 2, and the stderr. +fn late_error(args: &[&str], vars: &[(&str, &str)]) -> String { + let Run { status, stdout, stderr } = run_with(&[&LATE_ERROR[..], args].concat(), vars); + assert_eq!(status.code(), Some(2), "{args:?}: {stderr}"); + assert!(stderr.contains("cannot be used with"), "{args:?}: {stderr}"); + assert!(stdout.is_empty(), "{args:?}: {stdout}"); + stderr +} + #[test] fn help_prints_usage_and_exits_0() { for flag in ["--help", "-h"] { let Run { status, stdout, stderr } = run_photocraft(&[flag]); assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); - assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); - for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { + assert!(stdout.contains("Usage: photocraft"), "{flag} stdout: {stdout}"); + for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "`--`", "PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_NEW_INSTANCE"] { assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); } assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); @@ -50,7 +81,7 @@ fn help_prints_usage_and_exits_0() { fn help_wins_over_later_arguments() { let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]); assert_eq!(status.code(), Some(0)); - assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); + assert!(stdout.contains("Usage: photocraft"), "stdout: {stdout}"); } #[test] @@ -62,47 +93,65 @@ fn version_prints_the_long_version() { #[test] fn unknown_options_exit_2_naming_them() { - for flag in ["--safegpu", "-x", "--help=yes"] { - let Run { status, stdout, stderr } = run_photocraft(&[flag]); - assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); - assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); - assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); + for (args, named) in [ + (&["--safegpu"][..], "unexpected argument '--safegpu'"), + (&["-x"][..], "unexpected argument '-x'"), + (&["--help=yes"][..], "unexpected value 'yes' for '--help'"), + // An unknown flag after a bad value is reported first: clap drops the pending value's error. + (&["--control", "nope", "--safegpu"][..], "unexpected argument '--safegpu'"), + (&["--control", "nope"][..], "invalid value 'nope' for '--control '"), + ] { + let Run { status, stdout, stderr } = run_photocraft(args); + assert_eq!(status.code(), Some(2), "{args:?} must be a usage error, stderr: {stderr}"); + assert!(stderr.contains(named), "stderr: {stderr}"); + assert!(stderr.contains("--help"), "stderr should point at --help: {stderr}"); assert!(stdout.is_empty(), "stdout: {stdout}"); } } -#[test] -fn unknown_option_errors_are_reported_with_control_port_errors() { - let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "--safegpu"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); -} - #[test] fn arguments_after_double_dash_are_files() { // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage - // nor is an unknown option; only the bad port is reported. - let Run { status, stdout, stderr } = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); + // nor is an unknown option; only the late error is reported. + let stderr = late_error(&["--", "--help", "-x"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); } #[test] fn macos_process_serial_number_is_still_ignored() { // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. - let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "-psn_0_12345"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); + let stderr = late_error(&["-psn_0_12345"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); } #[test] fn new_instance_is_a_known_option() { - let Run { status, stderr, .. } = run_photocraft(&["--control", "nope", "--new-instance"]); + let stderr = late_error(&["--new-instance"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); +} + +/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad +/// value is a usage error naming the variable, and a flag without its value is still an error +/// when its variable is set (#6429). +#[test] +fn variables_are_the_flags_fallbacks() { + let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]); assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); + assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}"); + // With the flag (in LATE_ERROR), the variable is never read. + let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]); + assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}"); + // The variable turns the control server on as the flag does, so the token pair is checked. + let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr: {stderr}"); + let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("a value is required for '--automation-read-root '"), "stderr: {stderr}"); + // The other half of the token pair from a variable is the same either/or error, naming it + // but never showing the token. + let token = "a".repeat(64); + let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}"); } diff --git a/apps/photocraft/tests/it/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs index 3014bc1752..9494dbb43f 100644 --- a/apps/photocraft/tests/it/control_exit_code.rs +++ b/apps/photocraft/tests/it/control_exit_code.rs @@ -57,7 +57,8 @@ fn invalid_control_token_exits_with_status_2() { #[test] fn both_token_and_token_file_exits_with_status_2() { - // `server_token` rejects the combination of a supplied token and a token file. + // The command line rejects a supplied token together with a token file (#7071), before + // `server_token` would. let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let token_file = dir.join("token.txt"); @@ -65,7 +66,7 @@ fn both_token_and_token_file_exits_with_status_2() { let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]); std::fs::remove_dir_all(&dir).ok(); assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}"); - assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr: {stderr}"); } #[test] diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs index 236ffbbf3b..5c6dd666a7 100644 --- a/crates/automation/src/args.rs +++ b/crates/automation/src/args.rs @@ -1,6 +1,6 @@ //! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve` -//! flatten them, and the desktop app is to as well (#7071), so each flag and its -//! environment variable are declared once. A flag wins over its variable. +//! flatten them, and so does the desktop app (#7071), so each flag and its environment variable +//! are declared once. A flag wins over its variable. //! //! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset, //! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from. diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 34a9545a92..7fff71c67a 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -2,7 +2,7 @@ use std::fs::OpenOptions; use std::io::{BufRead, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -308,14 +308,6 @@ impl Read for AuthDeadlineStream { } } -/// Environment-aware token inputs for the desktop app. `photocraft-cli` declares the same variables -/// on its flags instead (`args::ControlArgs`). -pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { - let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); - let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from)); - (supplied, token_file) -} - #[cfg(test)] mod tests { use super::*; From e96b5e2b1124b21dcd7514fbd4b8957fc4eef4e1 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:57:43 +0300 Subject: [PATCH 30/40] docs(app): describe the desktop flags' variables and usage errors (#7071) Say that `photocraft --help` lists the flags and their variables, that a flag wins and an empty variable is unset, and what is a usage error; that a bad --present-mode or --frame-latency is one while a bad variable is still ignored; list PHOTOCRAFT_IN_WINDOW_MENUS; and that a token with a token file is a usage error for the control server too. Co-authored-by: Claude Opus 5.5 --- docs/control-protocol.md | 2 +- docs/development.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bea5d28b46..3d4d601b30 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -15,7 +15,7 @@ photocraft --control 7878 --control-token-file /private/path/photocraft-control. --automation-read-root /work/project --automation-write-root /work/project ``` -If the file does not exist, PhotoCraft creates it with a fresh 256-bit token. On Unix the new file is mode `0600`; on Windows, protect it with an appropriate user-only ACL. An existing file is reused. If neither a token nor token file is configured, PhotoCraft generates a token for that launch and writes it to standard error. `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` are the environment-variable equivalents. +If the file does not exist, PhotoCraft creates it with a fresh 256-bit token. On Unix the new file is mode `0600`; on Windows, protect it with an appropriate user-only ACL. An existing file is reused. If neither a token nor token file is configured, PhotoCraft generates a token for that launch and writes it to standard error. `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` are the environment-variable equivalents. A flag wins over its variable and an empty variable counts as unset; with `--control` (or `PHOTOCRAFT_CONTROL_PORT`), a token together with a token file, from flags or variables, is a usage error (exit status 2). Without it no token is read, so the pair is ignored. The first request on every TCP connection must authenticate. No other method is dispatched before this succeeds: diff --git a/docs/development.md b/docs/development.md index 81191d8e8d..641f9d5049 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,6 +143,8 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. ## Environment variables +`photocraft --help` lists the desktop app's flags and, beside the control, automation-root and in-window-menu flags, the variable that stands in for each. A flag wins over its variable and an empty variable counts as unset. A bad flag value is a usage error naming the flag (exit status 2), as is an unknown option, a repeated flag or a flag without its value; a bad value in one of those listed variables is a usage error naming the variable. `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` are not listed: a bad value there is logged and ignored. On Windows, release builds use the GUI subsystem and have no console, so `--help`, `--version` and usage errors print nothing in a terminal; redirect them to see the text (`photocraft --help | more` or `photocraft --help > help.txt`). The exit status is the same either way. + | Variable | Effect | |---|---| | `PHOTOCRAFT_CONTROL_PORT` | Same as `--control `; also the port for `photocraft-cli serve --port` and `mcp --bridge` when the flag is absent. Empty is unset | @@ -150,8 +152,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | | `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file | -| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | -| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | +| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root (`--automation-read-root`). Headless CLI modes require the flag | +| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root (`--automation-write-root`). Headless CLI modes require the flag | +| `PHOTOCRAFT_IN_WINDOW_MENUS=1` (or `--in-window-menus`) | macOS: keep the menus inside the window instead of the menu bar. `0`, `false`, `no`, `off` or empty is off | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Linux: use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch | @@ -164,8 +167,8 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | | `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | | `PHOTOCRAFT_STROKE_TRACE=1` (or `=`; also `--stroke-trace `, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | -| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace | -| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace | +| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace. A bad `--present-mode` is a usage error; a bad variable is logged and ignored | +| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace. A bad `--frame-latency` is a usage error; a bad variable is logged and ignored | | `PHOTOCRAFT_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | From 69d277440db6b45f9bc8aed43004bf335893ec85 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:58:05 +0300 Subject: [PATCH 31/40] feat(cli): generate man pages and shell completions (#7071) Add the man-and-completions example, which writes photocraft-cli's completions for bash, zsh, fish, elvish and PowerShell (clap_complete) and its man pages (clap_mangen: photocraft-cli.1 and one per subcommand, such as photocraft-cli-convert.1) into a directory: cargo run -p photocraft-cli --example man-and-completions -- clap_complete and clap_mangen are dev-dependencies, so the binary is unchanged. clap_mangen gets its env feature, so the pages name each flag's environment variable as --help does; clap already has env, so Cargo.lock gains no package for it. The library exposes only `command()`, the clap definition; the args module stays private. A test runs Command::debug_assert and checks that every shell's completions and every page generate. Another repeats the example's two generate_to calls and pins the exact file list: the five completion files under the names the packages install, photocraft-cli.1, one page per subcommand, and no pages for the help subcommand. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 27 +++++++++ Cargo.toml | 2 + apps/photocraft-cli/Cargo.toml | 4 ++ .../examples/man-and-completions.rs | 41 ++++++++++++++ apps/photocraft-cli/src/lib.rs | 8 +++ apps/photocraft-cli/tests/it/main.rs | 1 + .../tests/it/man_and_completions.rs | 55 +++++++++++++++++++ 7 files changed, 138 insertions(+) create mode 100644 apps/photocraft-cli/examples/man-and-completions.rs create mode 100644 apps/photocraft-cli/tests/it/man_and_completions.rs diff --git a/Cargo.lock b/Cargo.lock index 772c997a97..b90e1f5cd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,6 +1052,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -1070,6 +1079,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -4815,6 +4834,8 @@ name = "photocraft-cli" version = "0.15.1" dependencies = [ "clap", + "clap_complete", + "clap_mangen", "photocraft-automation", "photocraft-codecs", "photocraft-engine", @@ -6040,6 +6061,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "ron" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 5343ff7477..0787db33f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4.6.11" +clap_mangen = { version = "0.3.3", features = ["env"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index f646b5eb03..94e04b518a 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -36,6 +36,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } +# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in +# the binary. +clap_complete = { workspace = true } +clap_mangen = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. diff --git a/apps/photocraft-cli/examples/man-and-completions.rs b/apps/photocraft-cli/examples/man-and-completions.rs new file mode 100644 index 0000000000..ddb9cf0233 --- /dev/null +++ b/apps/photocraft-cli/examples/man-and-completions.rs @@ -0,0 +1,41 @@ +//! Writes photocraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish, +//! PowerShell) and man pages ([`clap_mangen`]: `photocraft-cli.1` and one per subcommand, such as +//! `photocraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them +//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`): +//! +//! ```sh +//! cargo run -p photocraft-cli --example man-and-completions -- +//! ``` + +use std::path::Path; +use std::process::ExitCode; + +use clap::ValueEnum; +use clap_complete::Shell; + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let (Some(dir), None) = (args.next(), args.next()) else { + eprintln!("usage: man-and-completions "); + return ExitCode::from(2); + }; + let dir = Path::new(&dir); + match generate(dir) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}: {e}", dir.display()); + ExitCode::FAILURE + } + } +} + +fn generate(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut cmd = photocraft_cli::command(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", dir)?; + } + // A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand, + // which `clap_mangen` would otherwise give pages of its own (`photocraft-cli-help-convert.1`). + clap_mangen::generate_to(photocraft_cli::command(), dir) +} diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 25743df2a9..c431b5529f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -6,6 +6,8 @@ use std::ffi::OsString; use std::io::Write; +use clap::CommandFactory; + mod actions; mod args; mod automation; @@ -21,6 +23,12 @@ use batch::{batch, droplet}; use document::{convert, info, run_cmds}; pub(crate) use error::{CliError, Result}; +/// The command line's clap definition, for generating shell completions and man pages +/// (`examples/man-and-completions.rs`). +pub fn command() -> clap::Command { + args::Cli::command() +} + /// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok, /// 1 failure, 2 usage). clap reads the arguments as they are: a path that is not valid Unicode (a /// Latin-1 file name on Linux) is taken, and one where text is expected is a usage error (exit 2). diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs index 581c983b3c..60cea6f74b 100644 --- a/apps/photocraft-cli/tests/it/main.rs +++ b/apps/photocraft-cli/tests/it/main.rs @@ -1,4 +1,5 @@ //! The crate's integration tests, linked into one test binary. mod cli; +mod man_and_completions; mod orientation; diff --git a/apps/photocraft-cli/tests/it/man_and_completions.rs b/apps/photocraft-cli/tests/it/man_and_completions.rs new file mode 100644 index 0000000000..9c1e2aabbf --- /dev/null +++ b/apps/photocraft-cli/tests/it/man_and_completions.rs @@ -0,0 +1,55 @@ +//! The clap definition is consistent, and every shell's completions and every man page +//! (`examples/man-and-completions.rs`) can be generated from it. + +use clap::ValueEnum; +use clap_complete::Shell; + +#[test] +fn command_is_consistent() { + photocraft_cli::command().debug_assert(); +} + +#[test] +fn completions_generate_for_every_shell() { + for &shell in Shell::value_variants() { + let mut out = Vec::new(); + clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out); + assert!(!out.is_empty(), "{shell}"); + } +} + +#[test] +fn man_pages_render_for_the_command_and_every_subcommand() { + let cmd = photocraft_cli::command(); + for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) { + let mut out = Vec::new(); + clap_mangen::Man::new(page.clone()).render(&mut out).unwrap(); + assert!(!out.is_empty(), "{}", page.get_name()); + } +} + +/// The example's two `generate_to` calls, repeated here, write the five completion files under the +/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1` +/// and a page per subcommand, and no pages for the `help` subcommand. +#[test] +fn the_example_writes_the_files_the_packages_install() { + let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut cmd = photocraft_cli::command(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap(); + } + clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap(); + + let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + written.sort(); + let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"]; + let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; + let mut expected: Vec = completions.map(String::from).into(); + expected.push("photocraft-cli.1".into()); + expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1"))); + expected.sort(); + assert_eq!(written, expected); + std::fs::remove_dir_all(&dir).unwrap(); +} From 92863642a74685cb219b3e15291c45810d175d70 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 16:59:47 +0300 Subject: [PATCH 32/40] feat(linux): ship photocraft-cli's man pages and completions (#7071) The deb, rpm and tarball install the man pages in /usr/share/man/man1 and the bash, zsh and fish completions in bash-completion's completions, /usr/share/zsh/site-functions and fish's vendor_completions.d. Fedora's and openSUSE's zsh read site-functions; Debian's reads /usr/share/zsh/vendor-completions instead (its zsh is configured with --enable-additional-fpath), so the deb also carries a symlink there. The man-and-completions example is built in the same cargo build as the binaries (--bins --example, same profile, features and target), and package.sh runs it from the target dir. A cross build runs it through $EMULATOR, as the --version smoke test does, and without one packages no pages or completions, with a warning. With --skip-build the example must already be built, or the script says so and fails. The pages aren't gzipped, like the other packaged docs. Co-authored-by: Claude Opus 5.5 --- docs/releasing.md | 8 ++++++-- packaging/linux/nfpm.yaml | 6 ++++++ packaging/linux/package.sh | 38 ++++++++++++++++++++++++++++++++++---- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 69a449f611..ce09583df4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -192,8 +192,12 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, `packaging/linux/package.sh` stages one FHS tree and makes every format from it. The tree holds both binaries, `ai.storyteller.photocraft.desktop`, hicolor icons from 16 px to 512 px -plus a scalable SVG, AppStream metainfo, and a shared-mime-info file for `.pcraft`, `.psb` and -`.qoi`. +plus a scalable SVG, AppStream metainfo, a shared-mime-info file for `.pcraft`, `.psb` and +`.qoi`, and `photocraft-cli`'s man pages and bash, zsh and fish completions. The +`man-and-completions` example, built with the binaries, writes those; a cross build runs it +through `EMULATOR` and leaves them out without one. The zsh completion is in +`/usr/share/zsh/site-functions`, which Fedora's and openSUSE's zsh read; the `.deb` also links it +from `/usr/share/zsh/vendor-completions`, which Debian's zsh reads instead. Why these formats: diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index bed6e1e9e9..c15b90c5b3 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -75,6 +75,12 @@ contents: - src: ${STAGE}/usr/share/ dst: /usr/share/ type: tree + # Debian's zsh reads vendor-completions, not /usr/share/zsh/site-functions as Fedora's and + # openSUSE's do (its debian/rules sets --enable-additional-fpath). + - src: /usr/share/zsh/site-functions/_photocraft-cli + dst: /usr/share/zsh/vendor-completions/_photocraft-cli + type: symlink + packager: deb scripts: postinstall: packaging/linux/postinst.sh diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index e245d254ca..5426ff0f11 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -16,7 +16,7 @@ # # Cross builds (CI: riscv64, tar.gz only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust # triple), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (e.g. qemu-riscv64) -# to smoke-test the CLI. +# to smoke-test the CLI and run the example that writes its man pages and completions. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -48,11 +48,15 @@ BASENAME="photocraft-$VERSION-linux-$ARCH" echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)" +# The man-and-completions example is built with the binaries: it writes photocraft-cli's man pages +# and shell completions below. if [[ "$SKIP_BUILD" = 0 ]]; then if [[ -n "${CROSS_TARGET:-}" ]]; then - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target "$CROSS_TARGET") + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ + --bins --example man-and-completions --target "$CROSS_TARGET") else - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ + --bins --example man-and-completions) fi fi BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" @@ -73,6 +77,28 @@ mkdir -p "$STAGE/usr/share/icons" cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/" mkdir -p "$STAGE/usr/share/doc/photocraft" copy_docs "$STAGE/usr/share/doc/photocraft" +# photocraft-cli's man pages and shell completions, from its clap definition, written by the +# example built above. A cross build runs it through $EMULATOR, like the --version check at the +# end, and leaves them out without one. zsh reads site-functions on Fedora and openSUSE; the deb +# also links the completion into Debian's vendor-completions (nfpm.yaml). +CLI_DOCS="$BIN/examples/man-and-completions" +if [[ -n "${CROSS_TARGET:-}" ]] && [[ -z "${EMULATOR:-}" ]]; then + warn "no EMULATOR to run the $CROSS_TARGET build, so photocraft-cli's man pages and completions are left out" +else + if [[ ! -x "$CLI_DOCS" ]]; then + echo "error: $CLI_DOCS is missing: build it with the binaries (without --skip-build)" >&2 + exit 1 + fi + if [[ -n "${CROSS_TARGET:-}" ]]; then + "$EMULATOR" "$CLI_DOCS" "$WORK/cli-docs" + else + "$CLI_DOCS" "$WORK/cli-docs" + fi + install -Dm644 -t "$STAGE/usr/share/man/man1" "$WORK"/cli-docs/*.1 + install -Dm644 "$WORK/cli-docs/photocraft-cli.bash" "$STAGE/usr/share/bash-completion/completions/photocraft-cli" + install -Dm644 "$WORK/cli-docs/_photocraft-cli" "$STAGE/usr/share/zsh/site-functions/_photocraft-cli" + install -Dm644 "$WORK/cli-docs/photocraft-cli.fish" "$STAGE/usr/share/fish/vendor_completions.d/photocraft-cli.fish" +fi if command -v desktop-file-validate >/dev/null; then desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop" @@ -97,7 +123,11 @@ if has deb || has rpm; then export VERSION export NFPM_ARCH="$DEB_ARCH" # nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`. - sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml" + # Without the completions (a cross build with no EMULATOR) the deb's zsh symlink would dangle, + # so its entry is dropped too. + DROP_ZSH_LINK= + [[ -f "$STAGE/usr/share/zsh/site-functions/_photocraft-cli" ]] || DROP_ZSH_LINK='\|^ - src: /usr/share/zsh/site-functions/|,/packager: deb/d;' + sed "${DROP_ZSH_LINK}s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml" for fmt in deb rpm; do if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi done From cb730955f60a15a0082c5adef5382f5cb7379882 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 17:00:08 +0300 Subject: [PATCH 33/40] feat(freebsd): ship photocraft-cli's man pages and completions (#7071) The FreeBSD tarball carries them in the ports layout: man/man1, share/bash-completion/completions, share/zsh/site-functions and share/fish/vendor_completions.d. The man-and-completions example is built in the same cargo build as the binaries (--bins --example), and package.sh runs it from the target dir. With --skip-build it must already be built, or the script says so and fails. A dry run builds nothing, so it leaves them out. Co-authored-by: Claude Opus 5.5 --- docs/releasing.md | 4 +++- packaging/freebsd/package.sh | 25 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index ce09583df4..81e24ba8ef 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -285,7 +285,9 @@ binaries with `CARGO_BUILD_JOBS=4` (more runs the 12 GB VM out of memory) and wr `x86_64` like the other artifacts. The tarball is a `/usr/local`-style tree: `bin/photocraft`, `bin/photocraft-cli`, and under `share/` the same desktop entry, MIME type, AppStream metainfo and hicolor icons as Linux, plus the licences, `NOTICE` and `ATTRIBUTION.md` in -`share/doc/photocraft/`. Users install it with: +`share/doc/photocraft/`, and `photocraft-cli`'s man pages (`man/man1/`) and bash, zsh and fish +completions (`share/bash-completion`, `share/zsh/site-functions`, `share/fish`), written by the +`man-and-completions` example built with the binaries. Users install it with: ```sh pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 71296da600..515285240f 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -2,8 +2,8 @@ # Build and package PhotoCraft for FreeBSD: # # $DIST/photocraft--freebsd-x86_64.tar.gz a /usr/local-style tree: -# photocraft--freebsd-x86_64/{bin, share/applications, share/icons, share/mime, -# share/metainfo, share/doc/photocraft} +# photocraft--freebsd-x86_64/{bin, man/man1, share/applications, share/icons, +# share/mime, share/metainfo, share/doc/photocraft, share/{bash-completion,zsh,fish}} # # Install by copying the tree's contents into /usr/local: # tar -xzf photocraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local @@ -46,8 +46,11 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" +# The man-and-completions example is built with the binaries: it writes photocraft-cli's man pages +# and shell completions below. if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ + --bins --example man-and-completions) fi WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" @@ -83,6 +86,22 @@ copy_docs "$STAGE/share/doc/photocraft" for f in NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi done +# photocraft-cli's man pages and shell completions, from its clap definition, written by the +# example built above. A dry run builds nothing, so it leaves them out. +if [[ "$DRY_RUN" = 0 ]]; then + CLI_DOCS="$BIN/examples/man-and-completions" + if [[ ! -x "$CLI_DOCS" ]]; then + echo "error: $CLI_DOCS is missing: build it with the binaries (without --skip-build)" >&2 + exit 1 + fi + "$CLI_DOCS" "$WORK/cli-docs" + mkdir -p "$STAGE/man/man1" "$STAGE/share/bash-completion/completions" \ + "$STAGE/share/zsh/site-functions" "$STAGE/share/fish/vendor_completions.d" + install -m 644 "$WORK"/cli-docs/*.1 "$STAGE/man/man1/" + install -m 644 "$WORK/cli-docs/photocraft-cli.bash" "$STAGE/share/bash-completion/completions/photocraft-cli" + install -m 644 "$WORK/cli-docs/_photocraft-cli" "$STAGE/share/zsh/site-functions/_photocraft-cli" + install -m 644 "$WORK/cli-docs/photocraft-cli.fish" "$STAGE/share/fish/vendor_completions.d/photocraft-cli.fish" +fi for f in bin/photocraft bin/photocraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ From 8544c985179f5b3ddbb0f34b37c177b69f89bb81 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 21 Oct 2026 14:58:05 +0300 Subject: [PATCH 34/40] docs(cli): installing and generating completions and man pages (#7071) Say which packages install them and how to generate and install them by hand from a checkout. The desktop app has neither. Co-authored-by: Claude Opus 5.5 --- book/src/automation/cli.md | 10 ++++++++++ docs/development.md | 1 + 2 files changed, 11 insertions(+) diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 488d3190a2..6137b12466 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -73,3 +73,13 @@ bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md `mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. ` --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`. This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters. + +### Shell completions and man pages + +The Linux `.deb` and `.rpm` packages install `man photocraft-cli` (plus a page per subcommand, such as `man photocraft-cli-convert`) and completions for bash, zsh and fish (zsh's in `/usr/share/zsh/site-functions`, which the `.deb` also links from Debian's `/usr/share/zsh/vendor-completions`); the Linux tarball and the FreeBSD tarball carry the same files, the FreeBSD one under `man/man1` and `share/`. The pages list each flag's environment variable, as `--help` does. Without a package, generate them from a source checkout: + +```sh +cargo run -p photocraft-cli --example man-and-completions -- out/ +``` + +`out/` then holds `photocraft-cli.bash`, `_photocraft-cli` (zsh), `photocraft-cli.fish`, `photocraft-cli.elv` (elvish), `_photocraft-cli.ps1` (PowerShell) and the `.1` man pages. For example, copy `photocraft-cli.bash` to `~/.local/share/bash-completion/completions/photocraft-cli`, `photocraft-cli.fish` into `~/.config/fish/completions/`, `_photocraft-cli` into a directory on zsh's `$fpath`, and read a page with `man -l out/photocraft-cli.1`. The desktop app `photocraft` has neither. diff --git a/docs/development.md b/docs/development.md index 641f9d5049..f94d55958e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -230,6 +230,7 @@ cargo run -p photocraft-cli -- run in.png --cmd layer.new.layer --params '{"name cargo run -p photocraft-cli -- run --new '{"width":800,"height":600}' --cmd document.inspect cargo run -p photocraft-cli -- batch --actions actions.json --in photos/ --out done/ --format jpg cargo run -p photocraft-cli -- commands --filter blur # the command registry +cargo run -p photocraft-cli --example man-and-completions -- out/ # man pages + bash/zsh/fish/elvish/PowerShell completions ``` `actions.json` holds the steps of a recorded action: `[["", {…}], …]`, `[{"command": "", "params": {…}}, …]` or bare ids, as a list or wrapped in `{"actions": …}` or `{"steps": …}`; a droplet file works too. It can also be an Actions set (the Actions panel's `actions.json`, `{"actions": [{"name": …, "steps": […]}, …]}`): `--action ` picks the action to play (optional when the set holds one), and the whole set is loaded for each file, so a step that plays another action of the set works. `batch` refuses an `--out` folder that is the `--in` folder, since the results would replace the originals, unless `--in-place` is given. `run` prints one JSON line per command result. From e279d5c8241cd108e71a906bc7e497b8eafcabd5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 18:58:09 +0200 Subject: [PATCH 35/40] PSD export: keep linked and embedded copies of the same contents apart in the cache Duplicates share a contents identity, so a linked and an embedded copy of the same bytes shared one cached conversion: whichever was written first decided for both. The cache key now includes whether the bytes came from a link. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/doc_path/tests.rs | 32 +++++++++++++++++++++++++++++ crates/io/src/psd_export.rs | 7 ++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 441ba5e702..fa806adf1e 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -287,3 +287,35 @@ photocraft-text-marker assert!(s.execute("layer.smartObjects.relinkToFile", json!({"layer": id.0, "path": abs.to_string_lossy()})).is_err(), "{path}"); } } + +/// Duplicates share their contents identity, so a linked and an embedded copy of the same bytes +/// meet in the export's conversion cache: each keeps its own rule, whichever is written first. +#[test] +fn a_linked_and_an_embedded_copy_of_the_same_bytes_keep_their_own_rule() { + const TEXT: &[u8] = b"plain text, not an image +photocraft-shared-marker +"; + let d = dir("shared-contents"); + std::fs::write(d.join("readme"), TEXT).unwrap(); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, linked) = document(&d, &d.join("red.png")); + s.execute("layer.duplicate", json!({})).unwrap(); + let embedded = s.active().unwrap().active_layer.unwrap(); + assert_ne!(linked, embedded); + relink(&mut s, linked, "readme"); + s.edit("Embed", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(embedded).map(|l| &mut l.content) { + sm.source = SmartSource::Embedded { file_name: "readme".into(), bytes: std::sync::Arc::new(TEXT.to_vec()) }; + } + Ok(()) + }) + .unwrap(); + for ext in ["psd", "psb", "tif"] { + let out = d.join(format!("copy.{ext}")); + let r = s.execute("file.saveACopy", json!({"path": out.to_string_lossy(), "tiffLayers": true})).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + let copies = bytes.windows(TEXT.len()).filter(|w| *w == TEXT).count(); + assert_eq!(copies, 1, "{ext}: only the embedded copy is kept: {r}"); + assert!(r["warnings"].to_string().contains("written as pixels"), "{ext}: {r}"); + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 20000e32dd..4835397822 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -169,8 +169,9 @@ struct SmartOut { prune_ok: bool, /// Files to embed. add: Vec, - /// Sources converted so far, by contents identity and bytes (`Err` = can't be embedded). - converted: std::collections::HashMap<(SmartContentsId, [u8; 32]), Result>, + /// Sources converted so far, by contents identity, bytes and whether they were read from a + /// link (`Err` = can't be embedded). + converted: std::collections::HashMap<(SmartContentsId, [u8; 32], bool), Result>, /// Each placed layer has its own id, even when it duplicates an imported layer. placed_used: std::collections::HashSet, /// Original instances reserve their imported placement regardless of layer order. @@ -1060,7 +1061,7 @@ impl Ex { /// `linked`: the bytes were read from a path the document names, so only a file PhotoCraft /// can open is embedded (a link to a text or settings file is written as pixels instead). fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { - let key = (contents_id, *blake3::hash(bytes).as_bytes()); + let key = (contents_id, *blake3::hash(bytes).as_bytes(), linked); if let Some(r) = self.smart.converted.get(&key) { return r.clone(); } From cd047a3ddc39e8de3909c3b12b334333317336c6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 19:12:31 +0200 Subject: [PATCH 36/40] Package: copy a linked file only when it is an image PhotoCraft can open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Package copied every linked file into Links and only then relinked the packaged document, so a link to a file that can't be placed left its copy behind and the package failed. Such a link is now listed as missing and not copied, like the PSD export rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/doc_path/tests.rs | 29 +++++++++++++++++++++++++++++ crates/engine/src/print_cmds.rs | 7 ++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index fa806adf1e..1c9bc88874 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -319,3 +319,32 @@ photocraft-shared-marker assert!(r["warnings"].to_string().contains("written as pixels"), "{ext}: {r}"); } } + +/// Package copies only linked files PhotoCraft can open: a link to a text file is listed as +/// missing and no copy of it is written into the package's Links folder. +#[test] +fn package_copies_only_links_to_images() { + const TEXT: &[u8] = b"plain text, not an image +photocraft-package-marker +"; + let d = dir("package-not-image"); + std::fs::write(d.join("readme"), TEXT).unwrap(); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "readme"); + let pkg = d.join("pkg"); + let r = s.execute("file.package", json!({"dir": pkg.to_string_lossy()})); + let copies: Vec<_> = walkdir(&pkg).into_iter().filter(|f| std::fs::read(f).is_ok_and(|b| b == TEXT)).collect(); + assert!(copies.is_empty(), "copied into the package: {copies:?} ({r:?})"); + let r = r.unwrap(); + assert_eq!(r["missing"], json!(["readme"]), "{r}"); +} + +fn walkdir(d: &Path) -> Vec { + let mut out = Vec::new(); + for e in std::fs::read_dir(d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { out.extend(walkdir(&p)) } else { out.push(p) } + } + out +} diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index a0395ff520..71efe29735 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -1156,13 +1156,14 @@ fn package(s: &mut Session, p: &Value) -> Result { missing.push(path.clone()); continue; }; - match read_file(&file.to_string_lossy()) { - Ok(bytes) => { + // Only a file PhotoCraft can place is copied; anything else is packaged as missing. + match read_file(&file.to_string_lossy()).ok().filter(|bytes| crate::file_cmds::import(&fname, bytes).is_ok()) { + Some(bytes) => { write_file(&t, &bytes)?; copied.push((path.clone(), t.clone())); t } - Err(_) => { + None => { missing.push(path.clone()); continue; } From ae75ef33bb4241eb32528c850c74cab8c07d2d87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 17:15:58 +0000 Subject: [PATCH 37/40] CLI batch: fmt, and valid values in the server option repeat cases A typed --bridge or --port value is parsed before clap sees the repeat, so "one" reported an invalid value instead of the repeated option. --- apps/photocraft-cli/tests/it/cli.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index aad93db025..5d87651385 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -166,13 +166,11 @@ fn server_modes_reject_ignored_or_conflicting_options() { } let transport = if sub == "mcp" { "--bridge" } else { "--port" }; for flag in [transport, "--control-token", "--control-token-file"] { - server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "cannot be used multiple times"); + // Valid values, so the repeat is what is refused (a transport value is parsed first). + server_usage_error(&[sub, flag, "7878", &format!("{flag}=7879")], "cannot be used multiple times"); server_usage_error(&[sub, flag, ""], &format!("'{flag} ")); } - server_usage_error( - &[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], - "cannot be used with", - ); + server_usage_error(&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], "cannot be used with"); } for flag in ["--automation-read-root", "--automation-write-root"] { server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app"); From 8950052c574074f1576683909eb3efd176be4574 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 17:27:54 +0000 Subject: [PATCH 38/40] Packaging: build the man-and-completions example in its own cargo run Built in the same run as the release binaries, the example's dev-dependencies would have their features unified into them. --- packaging/freebsd/package.sh | 9 +++++---- packaging/linux/package.sh | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 515285240f..4f99c415ac 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -46,11 +46,12 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -# The man-and-completions example is built with the binaries: it writes photocraft-cli's man pages -# and shell completions below. +# The man-and-completions example writes photocraft-cli's man pages and shell completions below. It +# is built in its own cargo run: built with the binaries, its dev-dependencies' features would be +# unified into them. if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ - --bins --example man-and-completions) + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins) + (cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions) fi WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 5426ff0f11..d93dab1870 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -48,15 +48,16 @@ BASENAME="photocraft-$VERSION-linux-$ARCH" echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)" -# The man-and-completions example is built with the binaries: it writes photocraft-cli's man pages -# and shell completions below. +# The man-and-completions example writes photocraft-cli's man pages and shell completions below. It +# is built in its own cargo run: built with the binaries, its dev-dependencies' features would be +# unified into them. if [[ "$SKIP_BUILD" = 0 ]]; then if [[ -n "${CROSS_TARGET:-}" ]]; then - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ - --bins --example man-and-completions --target "$CROSS_TARGET") + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins --target "$CROSS_TARGET") + (cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions --target "$CROSS_TARGET") else - (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif \ - --bins --example man-and-completions) + (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins) + (cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions) fi fi BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" From e3a8dc564a52638f6cce47640e977d9bc078dd7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 19:41:11 +0200 Subject: [PATCH 39/40] Tests: the workspace PSD-save test links a real image PSD export now writes a link to a file it can't open as pixels, so the test's trusted-export check embedded nothing. Linking a PNG keeps that check meaningful: the trusted export carries the file's exact bytes and the workspace save doesn't. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/automation/src/rpc.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 65e710e9d3..b98094eb5d 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -1048,13 +1048,16 @@ mod tests { fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() { use photocraft_engine::doc::{LayerContent, SmartSource}; - const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001"; - let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER); + // An image PhotoCraft can open (other linked files are never embedded): an embedded copy + // carries these exact bytes, while pixels written for the layer don't. + let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, (0..192u8).collect()).unwrap(); + let marker = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let has_marker = |bytes: &[u8]| bytes.windows(marker.len()).any(|w| w == marker.as_slice()); let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id())); let root = base.join("workspace"); std::fs::create_dir_all(&root).unwrap(); - let outside = base.join("outside.txt"); - std::fs::write(&outside, MARKER).unwrap(); + let outside = base.join("outside.png"); + std::fs::write(&outside, &marker).unwrap(); // A document whose smart object names a file outside the roots as its source. let mut session = photocraft_engine::Session::new(); session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); From 1a93824602561d0879ac53645876d3546fadad5f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 18:02:12 +0000 Subject: [PATCH 40/40] Packaging: --skip-build without the man-and-completions example leaves the CLI docs out Prebuilt binaries packaged with --skip-build failed when the example wasn't built with them. The package now leaves photocraft-cli's man pages and completions out with a warning, as a cross build without an emulator does. --- packaging/freebsd/package.sh | 13 ++++++------- packaging/linux/package.sh | 11 +++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 4f99c415ac..2d94ef57fb 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -88,13 +88,12 @@ for f in NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi done # photocraft-cli's man pages and shell completions, from its clap definition, written by the -# example built above. A dry run builds nothing, so it leaves them out. -if [[ "$DRY_RUN" = 0 ]]; then - CLI_DOCS="$BIN/examples/man-and-completions" - if [[ ! -x "$CLI_DOCS" ]]; then - echo "error: $CLI_DOCS is missing: build it with the binaries (without --skip-build)" >&2 - exit 1 - fi +# example built above. A dry run builds nothing, so it leaves them out, as a --skip-build run whose +# prebuilt binaries came without the example does. +CLI_DOCS="$BIN/examples/man-and-completions" +if [[ "$DRY_RUN" = 0 ]] && [[ ! -x "$CLI_DOCS" ]]; then + warn "$CLI_DOCS is not built, so photocraft-cli's man pages and completions are left out" +elif [[ "$DRY_RUN" = 0 ]]; then "$CLI_DOCS" "$WORK/cli-docs" mkdir -p "$STAGE/man/man1" "$STAGE/share/bash-completion/completions" \ "$STAGE/share/zsh/site-functions" "$STAGE/share/fish/vendor_completions.d" diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index d93dab1870..e24f8c09fc 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -80,16 +80,15 @@ mkdir -p "$STAGE/usr/share/doc/photocraft" copy_docs "$STAGE/usr/share/doc/photocraft" # photocraft-cli's man pages and shell completions, from its clap definition, written by the # example built above. A cross build runs it through $EMULATOR, like the --version check at the -# end, and leaves them out without one. zsh reads site-functions on Fedora and openSUSE; the deb -# also links the completion into Debian's vendor-completions (nfpm.yaml). +# end, and leaves them out without one, as a --skip-build run whose prebuilt binaries came without +# the example does. zsh reads site-functions on Fedora and openSUSE; the deb also links the +# completion into Debian's vendor-completions (nfpm.yaml). CLI_DOCS="$BIN/examples/man-and-completions" if [[ -n "${CROSS_TARGET:-}" ]] && [[ -z "${EMULATOR:-}" ]]; then warn "no EMULATOR to run the $CROSS_TARGET build, so photocraft-cli's man pages and completions are left out" +elif [[ ! -x "$CLI_DOCS" ]]; then + warn "$CLI_DOCS is not built, so photocraft-cli's man pages and completions are left out" else - if [[ ! -x "$CLI_DOCS" ]]; then - echo "error: $CLI_DOCS is missing: build it with the binaries (without --skip-build)" >&2 - exit 1 - fi if [[ -n "${CROSS_TARGET:-}" ]]; then "$EMULATOR" "$CLI_DOCS" "$WORK/cli-docs" else