mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 22:24:37 +00:00
Match Batch Esc flow and show read-only input preview
This commit is contained in:
1 parent
95a7d211c2
commit
a2c4ac0b0c
12 files changed
+610
-25
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user