Match Batch Esc flow and show read-only input preview

This commit is contained in:
Fabio Lissi committed 2026-11-02 17:36:55 -06:00
1 parent 95a7d211c2
commit a2c4ac0b0c
12 files changed
+610 -25

No files matched your search

+56 -9
View File
@@ -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::<Vec<_>>());
}
if let Some(error) = log_failure {
result["errorLogError"] = json!(error);
@@ -512,10 +554,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
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<Arc<Document>> = 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<Value> {
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<Value> {
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),
+194
View File
@@ -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");
+162 -3
View File
@@ -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<String>,
batch: Mutex<BatchInterrupt>,
batch_decision: Condvar,
batch_preview: Mutex<Option<BatchPreview>>,
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<Document>,
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<Arc<Document>>) {
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<BatchPreview> {
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<BatchPreview> {
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<JobId> = self.jobs.running.iter().map(|r| r.id).collect();
+33
View File
@@ -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();
+3
View File
@@ -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());
+86 -2
View File
@@ -175,6 +175,8 @@ pub struct JobsUi {
pub focus: Option<JobId>,
/// The job the latest [`run`] started (the control channel waits on it).
pub(crate) last_started: Option<JobId>,
/// 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<String> {
/// (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) }
+48
View File
@@ -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] {
+13
View File
@@ -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.
+6 -4
View File
@@ -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 splLine truncated
> **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 luminositLine truncated
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. |
+1 -1
View File
@@ -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 |
+7 -5
View File
@@ -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 |
+1 -1
View File
@@ -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"