From a2c4ac0b0c343a916de262e2be8a8a54b987e882 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 2 Nov 2026 17:36:55 -0600 Subject: [PATCH] Match Batch Esc flow and show read-only input preview --- crates/engine/src/cmd/file_cmds/batch_run.rs | 65 ++++++- crates/engine/src/cmd/file_cmds/tests.rs | 194 +++++++++++++++++++ crates/engine/src/jobs.rs | 165 +++++++++++++++- crates/engine/src/jobs/tests.rs | 33 ++++ crates/ui-egui/src/canvas/tabs.rs | 3 + crates/ui-egui/src/jobs_ui.rs | 88 ++++++++- crates/ui-egui/src/jobs_ui/tests.rs | 48 +++++ docs/atn.md | 13 ++ docs/gaps.md | 10 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 12 +- scorecard/automation.toml | 2 +- 12 files changed, 610 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 0c6e4790c7..c7e393bd1b 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -1,7 +1,7 @@ use super::open::import_at_raw_ppi; -use super::paths::{extension, file_name, join, list_images, read_file, stem}; +use super::paths::{extension, file_name, join, list_images, read_file, stem, write_file}; use super::processing::parse_steps; -use super::save::{SaveOpts, save_doc, save_doc_as}; +use super::save::{SaveOpts, encode, save_doc, save_doc_as}; use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param}; /// Inputs of a batch command: `"input"` is a folder or an array of files. @@ -215,15 +215,25 @@ fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: Pro let mut scratch = Session::new(); let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; scratch.add_document(doc, Some(path.to_string())); + if let Some(ctx) = options.ctx { + ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone())); + } let saved_by_action = f(&mut scratch, out)?; if let Some(ctx) = options.ctx { ctx.check()?; + if ctx.batch_interrupt_requested() { + return Err(EngineError::Cancelled); + } } if !saved_by_action { let d = scratch.active().ok_or(EngineError::NoDocument)?; if let Some(format) = options.format { let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; - save_doc_as(&d.doc, out, &ext, save)?; + if let Some(ctx) = options.ctx { + save_batch_doc(&d.doc, out, &ext, save, ctx)?; + } else { + save_doc_as(&d.doc, out, &ext, save)?; + } } else { save_doc(&d.doc, out, save)?; } @@ -231,6 +241,15 @@ fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: Pro Ok(()) } +/// Encode before the Batch write checkpoint. Esc accepted during an action or encode therefore +/// leaves the current destination untouched; the worker owns the write once the checkpoint passes. +fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result<()> { + let (bytes, _) = encode(doc, format, save)?; + ctx.batch_before_write()?; + write_file(out, &bytes)?; + Ok(()) +} + #[derive(Clone, Debug)] pub(super) struct BatchPolicy { pub(super) stop_on_error: bool, @@ -333,16 +352,36 @@ pub(super) fn process_batch_outputs( let mut errors = Vec::new(); let mut stopped = None; let mut done = 0; + let mut interrupted = Vec::new(); for (index, (path, out)) in outputs.iter().enumerate() { if ctx.cancelled() { break; } + if ctx.batch_take_interrupt() && !ctx.batch_wait_for_decision() { + ctx.publish_batch_preview(path, None); + ctx.cancel(); + break; + } ctx.progress(index as f32 / total.max(1) as f32, &format!("{} of {total}: {}", index + 1, file_name(path))); let result = process_one(raw_ppi, path, out, save, ProcessOptions { format: Some(format), written: None, ctx: Some(ctx) }, f); if ctx.cancelled() { + ctx.publish_batch_preview(path, None); done = index; break; } + if ctx.batch_take_interrupt() { + if ctx.batch_wait_for_decision() { + ctx.publish_batch_preview(path, None); + interrupted.push(path.clone()); + done = index + 1; + continue; + } + ctx.publish_batch_preview(path, None); + ctx.cancel(); + break; + } + ctx.batch_after_write(); + ctx.publish_batch_preview(path, None); done = index + 1; match result { Ok(()) => { @@ -362,11 +401,14 @@ pub(super) fn process_batch_outputs( if !cancelled { ctx.progress(1.0, ""); } - let skipped = outputs.iter().skip(done).map(|(path, _)| path.clone()).collect(); - let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped)), policy.error_log.as_deref().filter(|_| log_failure.is_none())); + let mut skipped = interrupted; + skipped.extend(outputs.iter().skip(done).map(|(path, _)| path.clone())); + let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped.clone())), policy.error_log.as_deref().filter(|_| log_failure.is_none())); + if !skipped.is_empty() { + result["skipped"] = json!(skipped); + } if cancelled { result["cancelled"] = json!(true); - result["skipped"] = json!(outputs.iter().skip(done).map(|(path, _)| path).collect::>()); } if let Some(error) = log_failure { result["errorLogError"] = json!(error); @@ -512,10 +554,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let result = (|| { // A recorded save marks a result. If the action later closes or switches // documents, write the saved snapshot instead of the new active document. - let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or(""); - let encode_format = if format == "same" { same_format(source) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or("").to_string(); + let encode_format = if format == "same" { same_format(&source) } else { format.trim_start_matches('.').to_ascii_lowercase() }; let mut saved: Option> = None; for (id, params) in &steps { + ctx.check()?; + if ctx.batch_interrupt_requested() { + return Err(EngineError::Cancelled); + } if batch_save_point(id) { let state = scratch.active_mut().ok_or(EngineError::NoDocument)?; saved = Some(state.doc.clone()); @@ -527,6 +573,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { continue; } let result = scratch.execute(id, params.clone())?; + ctx.publish_batch_preview(&source, scratch.active().map(|state| state.doc.clone())); if let Some(error) = crate::actions_cmds::nested_failure(id, &result) { return Err(EngineError::Other(error)); } @@ -534,7 +581,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { match saved { Some(doc) if scratch.active().is_none_or(|active| active.doc.id != doc.id) => { ctx.check()?; - save_doc_as(&doc, out, &encode_format, save)?; + save_batch_doc(&doc, out, &encode_format, save, ctx)?; Ok(true) } _ => Ok(false), diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index c528c437eb..808150c152 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -89,6 +89,200 @@ fn batch_cancelled_during_an_action_does_not_save_that_input() { assert!(!std::path::Path::new(&out).exists()); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_interruption_continues_with_the_original_next_output() { + use std::sync::mpsc; + use std::time::Duration; + + let dir = tmp("batch-interrupt-continue"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let first_out = join(&dir, "out/first_001.png"); + let second_out = join(&dir, "out/second_002.png"); + let ctx = crate::jobs::JobCtx::new(); + let worker_ctx = ctx.clone(); + let (opened_tx, opened_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let first_action = std::sync::atomic::AtomicBool::new(true); + let worker = std::thread::spawn(move || { + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + super::batch_run::process_batch_outputs( + 72.0, + vec![(first, first_out.clone()), (second, second_out.clone())], + "png", + SaveOpts::default(), + &policy, + &worker_ctx, + &|_, _| { + if first_action.swap(false, std::sync::atomic::Ordering::AcqRel) { + let _ = opened_tx.send(()); + let _ = resume_rx.recv_timeout(Duration::from_secs(10)); + } + Ok(false) + }, + ) + .map(|result| (result, first_out, second_out)) + }); + opened_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let preview = ctx.batch_preview().expect("the real opened input is published before its action"); + assert_eq!(preview.path, join(&dir, "first.png")); + assert_eq!((preview.document.size.width, preview.document.size.height), (8, 6)); + assert!(ctx.request_batch_interrupt(), "Esc is accepted while the first action runs"); + resume_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(ctx.batch_waiting_for_decision(), "the worker stopped before writing"); + assert!(!std::path::Path::new(&join(&dir, "out/first_001.png")).exists()); + assert!(ctx.decide_batch_interrupt(true)); + let (result, first_out, second_out) = worker.join().unwrap().unwrap(); + assert_eq!(result["files"], json!([second_out]), "Continue keeps the original serial: {result}"); + assert!(!std::path::Path::new(&first_out).exists()); + assert!(std::path::Path::new(&second_out).exists()); + assert!(ctx.batch_preview().is_none(), "the preview closes after the last input"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_stop_after_interruption_preserves_prior_outputs_and_never_saves_current() { + use std::sync::mpsc; + use std::time::Duration; + + let dir = tmp("batch-interrupt-stop"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let third = png(&dir, "third.png", 8, 6, "#0000ff"); + let out = join(&dir, "out"); + let first_out = join(&out, "first.png"); + let second_out = join(&out, "second.png"); + let third_out = join(&out, "third.png"); + let ctx = crate::jobs::JobCtx::new(); + let worker_ctx = ctx.clone(); + let (opened_tx, opened_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + super::batch_run::process_batch_outputs( + 72.0, + vec![(first, first_out), (second, second_out), (third, third_out)], + "png", + SaveOpts::default(), + &policy, + &worker_ctx, + &|_, path| { + if path.ends_with("second.png") { + let _ = opened_tx.send(()); + let _ = resume_rx.recv_timeout(Duration::from_secs(10)); + } + Ok(false) + }, + ) + }); + opened_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(ctx.request_batch_interrupt()); + resume_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(ctx.batch_waiting_for_decision()); + assert!(ctx.decide_batch_interrupt(false)); + let result = worker.join().unwrap().unwrap(); + assert_eq!(result["files"], json!([join(&out, "first.png")]), "{result}"); + assert_eq!(result["cancelled"], true, "{result}"); + assert_eq!(result["skipped"], json!([join(&dir, "second.png"), join(&dir, "third.png")]), "{result}"); + assert!(std::path::Path::new(&join(&out, "first.png")).exists()); + assert!(!std::path::Path::new(&join(&out, "second.png")).exists()); + assert!(!std::path::Path::new(&join(&out, "third.png")).exists()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_continue_keeps_errors_before_and_after_the_interruption_in_one_log() { + use std::time::Duration; + + let dir = tmp("batch-interrupt-error-log"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let fourth = png(&dir, "fourth.png", 8, 6, "#0000ff"); + let before = join(&dir, "missing-before.png"); + let after = join(&dir, "missing-after.png"); + let out = join(&dir, "out"); + let log = join(&dir, "errors.txt"); + let ctx = crate::jobs::JobCtx::new(); + let decider_ctx = ctx.clone(); + let decider = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + if !decider_ctx.batch_waiting_for_decision() { + decider_ctx.cancel(); + return false; + } + decider_ctx.decide_batch_interrupt(true) + }); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: Some(log.clone()) }; + let result = super::batch_run::process_batch_outputs( + 72.0, + vec![ + (before.clone(), join(&out, "missing-before.png")), + (second.clone(), join(&out, "second.png")), + (after.clone(), join(&out, "missing-after.png")), + (fourth, join(&out, "fourth.png")), + ], + "png", + SaveOpts::default(), + &policy, + &ctx, + &|_, path| { + if path.ends_with("second.png") { + assert!(ctx.request_batch_interrupt()); + } + Ok(false) + }, + ) + .unwrap(); + assert!(decider.join().unwrap()); + assert_eq!(result["errors"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(result["skipped"], json!([second]), "{result}"); + assert_eq!(result["files"], json!([join(&out, "fourth.png")]), "{result}"); + let text = batch_log(&log); + assert!(text.contains("missing-before.png") && text.contains("missing-after.png"), "{text}"); + assert!(!text.contains("second.png"), "a skipped input is not an error: {text}"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_continue_between_inputs_still_processes_the_next_input() { + use std::time::Duration; + + let dir = tmp("batch-interrupt-between-inputs"); + let input = png(&dir, "next.png", 8, 6, "#ff0000"); + let output = join(&dir, "out/next.png"); + let ctx = crate::jobs::JobCtx::new(); + assert!(ctx.request_batch_interrupt()); + let decider_ctx = ctx.clone(); + let decider = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + if !decider_ctx.batch_waiting_for_decision() { + decider_ctx.cancel(); + return false; + } + decider_ctx.decide_batch_interrupt(true) + }); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let result = + super::batch_run::process_batch_outputs(72.0, vec![(input, output.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| Ok(false)).unwrap(); + assert!(decider.join().unwrap()); + assert_eq!(result["files"], json!([output]), "the unopened input is still next: {result}"); + assert!(result.get("skipped").is_none(), "{result}"); +} + #[test] fn batch_error_log_cannot_replace_an_input_or_result() { let dir = tmp("batch-error-log-collision"); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index f167deae32..ff22f197e0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -20,7 +20,7 @@ use std::any::Any; use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; -use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::sync::{Arc, Condvar, Mutex, PoisonError, Weak}; use photocraft_doc::{DocId, Document, LayerId}; use photocraft_raster::Interrupt; @@ -89,6 +89,33 @@ struct Shared { /// `fetch_max` keeps progress monotonic even with several reporting threads. progress: AtomicU32, message: Mutex, + batch: Mutex, + batch_decision: Condvar, + batch_preview: Mutex>, + batch_preview_generation: std::sync::atomic::AtomicU64, +} + +/// The current folder Batch input, published as an immutable snapshot for the desktop canvas. +/// It is never added to the live session and cannot receive commands or saves. +#[derive(Clone, Debug)] +pub struct BatchPreview { + pub path: String, + pub document: Arc, + pub generation: u64, +} + +/// The desktop Batch Esc handshake. The worker alone enters `Waiting` at a pre-save +/// checkpoint; only the UI supplies a decision. Programmatic cancellation uses the separate +/// atomic flag above and wakes a waiting worker without asking a question. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum BatchInterrupt { + #[default] + Processing, + Requested, + Waiting, + Continue, + Stop, + Saving, } const CANCEL_REQUESTED: u8 = 1; @@ -127,15 +154,122 @@ impl JobCtx { /// Ask the job to stop (idempotent). pub fn cancel(&self) { self.shared.state.fetch_or(CANCEL_REQUESTED, Ordering::AcqRel); + let _phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + self.shared.batch_decision.notify_all(); } /// Accept a cancellation only while the worker has not finished its result. One atomic state /// orders a late cancel against completion, including the interval before the UI polls. fn cancel_if_running(&self) -> bool { - self.shared + let accepted = self + .shared .state .try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & (CANCEL_REQUESTED | WORK_FINISHED) == 0).then_some(state | CANCEL_REQUESTED)) - .is_ok() + .is_ok(); + if accepted { + let _phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + self.shared.batch_decision.notify_all(); + } + accepted + } + + /// Request the reference application's Esc decision for an interactive Batch. A save that + /// passed its final write checkpoint cannot be interrupted; the caller may try the next + /// input. This does not affect the direct `jobs.cancel` automation API. + pub fn request_batch_interrupt(&self) -> bool { + if self.cancelled() || self.shared.state.load(Ordering::Acquire) & WORK_FINISHED != 0 { + return false; + } + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Processing { + return false; + } + *phase = BatchInterrupt::Requested; + true + } + + /// The worker has stopped before writing the current input and awaits Continue or Stop. + pub fn batch_waiting_for_decision(&self) -> bool { + *self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Waiting + } + + /// Answer a waiting interactive Batch question. `continue_next` skips the current input. + pub fn decide_batch_interrupt(&self, continue_next: bool) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Waiting { + return false; + } + *phase = if continue_next { BatchInterrupt::Continue } else { BatchInterrupt::Stop }; + self.shared.batch_decision.notify_all(); + true + } + + /// Whether an action should stop before its next step because Esc was requested. + pub fn batch_interrupt_requested(&self) -> bool { + *self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Requested + } + + /// Move an accepted Esc request to the question at a worker checkpoint. + pub fn batch_take_interrupt(&self) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Requested { + return *phase == BatchInterrupt::Waiting; + } + *phase = BatchInterrupt::Waiting; + true + } + + /// Wait for the UI decision without polling. Closing the session or `jobs.cancel` wakes this + /// worker; the caller then reports its partial result. + pub fn batch_wait_for_decision(&self) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + while *phase == BatchInterrupt::Waiting && !self.cancelled() { + phase = self.shared.batch_decision.wait(phase).unwrap_or_else(PoisonError::into_inner); + } + let continue_next = *phase == BatchInterrupt::Continue && !self.cancelled(); + *phase = BatchInterrupt::Processing; + continue_next + } + + /// Mark the start of an input's output write. The same lock orders Esc against this + /// checkpoint: a requested interruption never writes that input. + pub(crate) fn batch_before_write(&self) -> Result<()> { + self.check()?; + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase == BatchInterrupt::Requested { + *phase = BatchInterrupt::Waiting; + return Err(EngineError::Cancelled); + } + if self.cancelled() { + return Err(EngineError::Cancelled); + } + *phase = BatchInterrupt::Saving; + Ok(()) + } + + /// A completed write allows Esc on the next input. + pub(crate) fn batch_after_write(&self) { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase == BatchInterrupt::Saving { + *phase = BatchInterrupt::Processing; + } + } + + /// Publish the current input's document after it opens or an action step changes it. + pub fn publish_batch_preview(&self, path: &str, document: Option>) { + let mut preview = self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner); + if preview.as_ref().zip(document.as_ref()).is_some_and(|(shown, next)| shown.path == path && Arc::ptr_eq(&shown.document, next)) + || (preview.is_none() && document.is_none()) + { + return; + } + let generation = self.shared.batch_preview_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1); + *preview = document.map(|document| BatchPreview { path: path.to_string(), document, generation }); + } + + /// A snapshot for rendering, if the current input still has a document open. + pub fn batch_preview(&self) -> Option { + self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner).clone() } #[cfg(not(target_arch = "wasm32"))] @@ -756,6 +890,31 @@ impl Session { true } + /// Ask an interactive Batch worker to stop its current input at the next safe checkpoint. + /// Headless clients keep using [`Self::cancel_job`] for a direct partial result. + pub fn request_batch_interrupt_job(&self, id: JobId) -> bool { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.request_batch_interrupt()) + } + + /// Whether a Batch worker has acknowledged Esc and is waiting for the desktop decision. + pub fn batch_interrupt_waiting(&self, id: JobId) -> bool { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.batch_waiting_for_decision()) + } + + /// Continue with the next input or stop a Batch paused by the desktop Esc key. + pub fn decide_batch_interrupt_job(&self, id: JobId, continue_next: bool) -> bool { + self.jobs + .running + .iter() + .find(|job| job.id == id && job.command == "file.automate.batch") + .is_some_and(|job| job.ctx.decide_batch_interrupt(continue_next)) + } + + /// The current read-only folder Batch document shown by the desktop while work runs. + pub fn batch_preview(&self, id: JobId) -> Option { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").and_then(|job| job.ctx.batch_preview()) + } + /// Cancel every running job (e.g. before quitting). pub fn cancel_all_jobs(&mut self) { let ids: Vec = self.jobs.running.iter().map(|r| r.id).collect(); diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 4249c809ac..cda760186d 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -573,6 +573,39 @@ fn cancelled_batch_job_returns_its_partial_result() { assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn programmatic_cancel_wakes_a_batch_waiting_for_the_desktop_decision() { + let mut s = Session::new(); + let id = job(s + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.batch_take_interrupt() { + ctx.check()?; + std::thread::yield_now(); + } + let continued = ctx.batch_wait_for_decision(); + Ok(json!({"continued": continued, "files": [], "cancelled": ctx.cancelled()})) + }, + |_, result| Ok(result), + ) + .unwrap()); + assert!(s.request_batch_interrupt_job(id)); + let deadline = Instant::now() + Duration::from_secs(10); + while !s.batch_interrupt_waiting(id) && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(s.batch_interrupt_waiting(id)); + assert!(s.cancel_job(id), "jobs.cancel remains direct and wakes the worker"); + let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("batch partial result was lost") }; + assert_eq!(result["continued"], false); + assert_eq!(result["cancelled"], true); +} + #[test] fn accepted_batch_cancellation_after_result_snapshot_is_reported() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 2ab777b532..5643d2c45f 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -5,6 +5,9 @@ use super::{Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, TextureOptions, c /// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto /// another document (`layer_transfer`). pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if crate::jobs_ui::batch_preview(app, ui) { + return; + } crate::distort_ui::drop_foreign(app); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index bdc641468e..b1ad5d8b73 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -175,6 +175,8 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// Cached read-only canvas image of the current folder Batch input. + batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } /// Run command `id`: in the background when background jobs are on and the command is a job @@ -385,7 +387,15 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { && !crate::zoom_field::editing(ctx) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - cancel(app, job.id); + if job.command == "file.automate.batch" { + if app.session.batch_interrupt_waiting(job.id) { + app.session.decide_batch_interrupt_job(job.id, false); + } else { + app.session.request_batch_interrupt_job(job.id); + } + } else { + cancel(app, job.id); + } } } @@ -707,6 +717,9 @@ pub fn save_progress(app: &PhotocraftApp, doc: DocId) -> Option { /// (the file name on hover). Draws nothing when no job runs. pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(j) = job_in_view(app) else { return }; + if j.command == "file.automate.batch" { + return; + } let saving = app.jobs.saves.iter().any(|s| s.job == j.id); let t = Tokens::get(ui.ctx()); let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; @@ -738,17 +751,63 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Show the folder Batch worker's immutable current document in the canvas area. The worker +/// publishes snapshots rather than adding them to `Session`, so no edit or Save can target one. +pub(crate) fn batch_preview(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { + let current = app + .session + .jobs() + .into_iter() + .filter(|job| job.command == "file.automate.batch") + .find_map(|job| app.session.batch_preview(job.id).map(|preview| (job.id, preview))); + let Some((id, preview)) = current else { + app.jobs.batch_preview_texture = None; + return false; + }; + if !app.jobs.batch_preview_texture.as_ref().is_some_and(|(cached_id, generation, _)| *cached_id == id && *generation == preview.generation) { + let image = photocraft_compose::thumbnail(&preview.document, 1024); + let pixels = egui::ColorImage::from_rgba_unmultiplied([image.width as usize, image.height as usize], &image.pixels); + let texture = ui.ctx().load_texture(format!("batch-preview-{}-{}", id.0, preview.generation), pixels, egui::TextureOptions::LINEAR); + app.jobs.batch_preview_texture = Some((id, preview.generation, texture)); + } + let Some((_, _, texture)) = &app.jobs.batch_preview_texture else { return false }; + let t = Tokens::get(ui.ctx()); + let name = std::path::Path::new(&preview.path).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_else(|| preview.path.clone()); + ui.horizontal(|ui| { + ui.label(RichText::new(name).color(t.text).font(crate::theme::semibold(12.5))); + ui.label(RichText::new(tl!("Batch preview")).color(t.text_faint).size(11.0)); + }); + let area = ui.available_rect_before_wrap(); + ui.allocate_rect(area, Sense::hover()); + let size = texture.size_vec2(); + if size.x > 0.0 && size.y > 0.0 { + let scale = (area.width() / size.x).min(area.height() / size.y); + if !scale.is_finite() { + return true; + } + let scale = scale.clamp(0.0, 1.0); + let target = Rect::from_center_size(area.center(), size * scale); + ui.painter().rect_filled(target, 0.0, Color32::WHITE); + ui.painter().image(texture.id(), target, Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE); + } + true +} + /// The modal progress window for a job locking the active document (Photoshop shows one for /// long filters): title, status, bar, percentage and elapsed time, Cancel. Appears after /// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). A save shows it only when it runs in the /// foreground (Save in Background off), and then at once, so nothing can be clicked meanwhile; a /// background save shows its progress in the tab and the status bar instead. pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { + if let Some(job) = app.session.jobs().into_iter().find(|job| job.command == "file.automate.batch" && app.session.batch_interrupt_waiting(job.id)) { + batch_interrupt_dialog(app, ctx, job.id); + return; + } let j = match foreground_save(app) { Some(j) => j, None => { let Some(j) = app.session.active_job() else { return }; - if j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -788,6 +847,31 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's question after Esc interrupts one input. The worker is parked before its save; +/// Continue advances the same run to the next input, and Stop returns its partial result. +fn batch_interrupt_dialog(app: &mut PhotocraftApp, ctx: &egui::Context, id: JobId) { + let t = Tokens::get(ctx); + let mut decision = None; + egui::Modal::new(egui::Id::unique("batch-interrupted")).show(ctx, |ui| { + ui.set_width(390.0); + ui.label(RichText::new(tl!("Batch")).font(crate::theme::semibold(15.0))); + ui.add_space(8.0); + ui.label(RichText::new(tl!("A command was cancelled or stopped during batch processing. Continue with the next file or stop?")).color(t.text_dim)); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, tl!("Continue"), 96.0).clicked() { + decision = Some(true); + } + if crate::widgets::secondary_button(ui, tl!("Stop"), 84.0).clicked() { + decision = Some(false); + } + }); + }); + if let Some(continue_next) = decision { + app.session.decide_batch_interrupt_job(id, continue_next); + } +} + fn elapsed(ms: f64) -> String { let s = (ms / 1000.0).max(0.0); if s < 60.0 { format!("{s:.1} s") } else { format!("{}:{:02}", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs index e33db9df8d..e7e5105796 100644 --- a/crates/ui-egui/src/jobs_ui/tests.rs +++ b/crates/ui-egui/src/jobs_ui/tests.rs @@ -798,6 +798,54 @@ fn cancelled_batch_with_partial_results_reports_cancellation_without_beeping() { assert_eq!(reply["result"]["files"], json!(["done.png"])); } +#[test] +fn interactive_batch_shows_its_read_only_input_and_asks_after_escape() { + let mut h = app_harness(); + let original = h.state().session.active().unwrap().doc.clone(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + move |ctx| { + let mut scratch = photocraft_engine::Session::new(); + scratch.execute("file.new", json!({"name": "input.png", "width": 16, "height": 12, "background": "#00ff00"}))?; + ctx.publish_batch_preview("/source/input.png", scratch.active().map(|state| state.doc.clone())); + let _ = ready_tx.send(()); + while !ctx.batch_take_interrupt() { + ctx.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + let continued = ctx.batch_wait_for_decision(); + ctx.publish_batch_preview("/source/input.png", None); + Ok(json!({"continued": continued, "files": []})) + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("batch did not start as a job") + }; + ready_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + frames_until(&mut h, 1, |h| h.query_by_label("Batch preview").is_some()); + assert!(h.query_by_label("input.png").is_some(), "the current input appears in the canvas"); + assert!(h.query_by_label("Batch").is_none(), "the Batch progress readout is hidden while its input is visible"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc), "preview cannot replace the editable document"); + h.key_press(egui::Key::Escape); + frames_until(&mut h, 1, |h| h.query_by_label("Continue").is_some()); + assert!(h.state().session.batch_interrupt_waiting(id)); + h.get_by_label("Continue").click(); + frames_until(&mut h, 1, |h| h.state().session.job(id).is_none()); + let result = h.state().session.jobs_with_recent().into_iter().find(|job| job.id == id).and_then(|job| job.result).unwrap(); + assert_eq!(result["continued"], true); + assert!(h.query_by_label("Batch preview").is_none(), "the input closes after the decision"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc)); +} + #[test] fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { for success in [true, false] { diff --git a/docs/atn.md b/docs/atn.md index 99f883fc98..26894a6dc7 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -561,6 +561,19 @@ Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png during batch processing. Continue with the next file or stop?" (Continue / Stop). Stop ends the batch at once; the file being processed is not saved (12 of 60 written in the test). There is no progress bar: the documents open and close in the window as they are processed. + +Batch cancellation conformance (Photoshop 2026 observation above; PhotoCraft's producer is +`file_cmds::batch_run::process_batch_outputs`, and its desktop consumer is `jobs_ui::tick`): + +| Reference step | Acceptance gate | Status | +|---|---|---| +| Plan folder order, names and serials once | Continue retains the original plan and next serial | Implemented and tested; Esc between inputs does not skip the unopened input | +| Show the opened input while its action runs | A read-only document preview appears in the canvas and closes with the input | Implemented and visually checked; deliberate deviation: worker snapshots are not live tabs or Layers panel targets, so they cannot be edited or saved by mistake | +| Interrupt the current command on Esc | The current input reaches a checkpoint before any output write | Implemented and tested before save; once the crash-safe atomic write begins, Esc is refused because publication cannot be retracted | +| Ask Continue or Stop | Continue skips the interrupted input; Stop returns prior outputs and skips the rest | Implemented and tested in engine and UI | +| Keep headless cancellation direct | `jobs.cancel` returns partial results without an interactive question | Implemented and tested, including a worker waiting for a desktop decision | +| No progress bar | The desktop Batch preview is the visible feedback | Implemented, UI tested and visually checked | +| Preserve error policy and log | Continue retains earlier errors and writes one final UTF-16 LE log | Implemented and tested with errors before and after Esc | - **Return value:** when any file failed, the Batch step itself reports "cancelled" (the dialog returns no descriptor); a script run with Stop For Errors reports success even though it stopped. diff --git a/docs/gaps.md b/docs/gaps.md index 6e489249d0..77fdc65aef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -114,9 +114,10 @@ what an agent can't finish alone. - **Partial 2026-11-02 (#4579):** Batch accepts recursive folders with flat output and last-result-wins names, as measured in Photoshop 2026. It also accepts live open documents, extended naming components, stop-on-error, UTF-16 LE logs, and job cancellation. The CLI - exposes the folder options. AUTO-219-5 remains partial: the UI shows job progress and cancels - directly instead of opening Photoshop's Continue/Stop prompt; scratch sessions do not show - each input in the window. No readiness percentage or estimate changed. + exposes the folder options. The desktop now asks Continue/Stop after Esc, preserves the + original output plan and cumulative error log, and shows each folder input as a read-only + canvas preview without a progress bar. AUTO-219-5 remains partial because worker inputs are + not live tabs with Layers panel content. No readiness percentage or estimate changed. - **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 120 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-31 evidence (#10608):** Group F selection, paste, Fade, Link, Distribute, pattern and older mask/merge events load, play and save through existing commands. Unsupported settings remain kept; AUTO-219-2 remains partial. - **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 128 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. @@ -433,6 +434,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | | 2026-11-02 | minor | G11: measured Batch output is flat and later results replace earlier ones; naming spells the whole filename; UTF-16 LE logs; current progress and cancellation UI tracked as remaining parity work (#4579); AUTO-219-5 partial; readiness and estimate unchanged | | 2026-11-01 | minor | G11: Batch recursion, open-document source, extended naming, stop/error log and folder-job progress/cancel (#4579); AUTO-219-5 done; readiness and estimate unchanged | | 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 120 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | diff --git a/docs/scorecard.md b/docs/scorecard.md index f5686f16cf..df4f6ffa8f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -259,7 +259,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. The UI still shows background progress and cancels immediately instead of showing Photoshop's Continue/Stop prompt; folder processing uses scratch sessions rather than opening each file in the window. | +| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 845a698121..3913ec3549 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -183,10 +183,11 @@ parity for the area and are already contained in the dimension rows above. | Workspace, panels, preferences | 40% | 150–250 | 11 of 183 prefs do nothing (scorecard, 2026-11-01); no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | -Batch supports [output naming templates, recursive folders, open documents, per-file progress, -cancellation, stop-on-error and error logs](batch-naming.md); measured output names and UTF-16 -logs match Photoshop 2026, while the progress display and Continue/Stop cancellation prompt -remain different. AUTO-219-5 is partial (#4579). +Batch supports [output naming templates, recursive folders, open documents, Esc Continue/Stop, +headless cancellation, stop-on-error and error logs](batch-naming.md); measured output names and +UTF-16 logs match Photoshop 2026. The desktop shows each folder input as a read-only canvas +snapshot while its action runs, with no progress bar. Full live tabs and Layers panel content for +those inputs remain different. AUTO-219-5 is partial (#4579). ## Remaining effort and calibration @@ -588,6 +589,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Automation Batch: Esc Continue/Stop preserves one file plan and error log; a read-only input preview replaces the progress bar; full live tabs remain open, AUTO-219-5 partial (#4579); percentages and estimates unchanged | | 2026-11-02 | minor | Automation Batch: flat subfolder output, last-result-wins naming and UTF-16 LE logs matched to measured Photoshop 2026; progress and cancel prompt remain partial, AUTO-219-5 (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | Automation Batch: recursive folders, open documents, extended naming, stop/error log and folder-job progress/cancel; AUTO-219-5 done (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | UI / UX and Workspace rows: 11 of 183 preferences do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see. Percentages and estimates unchanged | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 89c337903c..311fed18b5 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -44,7 +44,7 @@ id = "AUTO-219-5" target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop" status = "partial" issue = 219 -note = "file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. The UI still shows background progress and cancels immediately instead of showing Photoshop's Continue/Stop prompt; folder processing uses scratch sessions rather than opening each file in the window." +note = "file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open." [[item]] id = "AUTO-219-6"