fix(engine): adjustments on a smart object record smart filters (FILE-215-4); strokes honour doc CMYK (#2229)

On a smart-object layer, Image > Adjustments now records an editable
smart filter instead of committing disaster.

  - new engine helpers:
    - adjust_cmds::has_adjustable / adjust_as_smart_filter route
      image.adjustments.<kind> onto the source, with the selection as the
      filter mask, through smart_cmds::add_smart_filter
    - brush/eraser/pencil/live preview strokes now render inside
      with_cmyk_space(doc_cmyk_space(session)), so painted colours follow the
      document profile the same way the compositor does
  - docs/scorecard.md: FILE-215-4 partial -> done
  - tests: adjust_cmds/tests + brush_cmds/tests, plus panic_hunt and
    the lib suite all green on the committed tree

Known follow-up, already filed:
  - FILE-215-5 rows: double-clicking an adjustment smart-filter row is still
    a no-op. smart_ui::open_editor only proceeds when
    filter_dialog::has_dialog returns true, and has_dialog accepts
    filter.*, a fixed non-adjustment set and PREVIEWED - but not
    image.adjustments.* (those open through adjust_dialog::open, which isn't
    wired into the smart-filter row).

This is one commit on top of the latest upstream/main (rebased to fix the
stale fork ancestry that was blocking PR creation).

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
kubuntugamerandBrandon Thomas authored and GitHub committed 2026-10-10 05:49:07 -04:00
1 parent 29d5f791d5
commit 4c06b9fe4d
7 files changed
+299 -85

No files matched your search

+50 -1
View File
@@ -250,6 +250,52 @@ fn has_pixels(s: &Session) -> std::result::Result<(), String> {
}
}
/// Image › Adjustments targets: a pixel layer, a smart object (the adjustment is recorded as a
/// smart filter, as in Photoshop), or a targeted alpha channel / Quick Mask.
pub(crate) fn has_adjustable(s: &Session) -> std::result::Result<(), String> {
if crate::channel_cmds::edits_channel(s) {
return Ok(());
}
let d = s.active().ok_or("no document open")?;
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
match &l.content {
LayerContent::Raster(_) => Ok(()),
LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()),
other => Err(format!("active layer is {} {} layer, not a pixel layer", other.article(), other.kind_name())),
}
}
/// Image › Adjustments on a smart object record `image.adjustments.<kind>` as a smart filter
/// (Photoshop): the adjustment re-runs from the source whenever the object re-renders, through
/// [`adjust_as_filter`]. `Ok(true)` when it was recorded (one history step, the selection becomes
/// the filter mask); `Ok(false)` when the command should edit pixels instead (a mask or channel
/// target, or the active layer is not a smart object).
pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p: &Value) -> Result<bool> {
if crate::commands::is_mask_target(p) || crate::channel_cmds::is_channel_target(p) {
return Ok(false);
}
let Some(id) = s.active().and_then(|d| d.active_layer) else { return Ok(false) };
let smart = s.active().is_some_and(|d| matches!(d.doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_))));
if !smart {
return Ok(false);
}
// The recorded params are the command's own, minus the engine's private `__kind` key.
let mut params = p.clone();
if let Value::Object(m) = &mut params {
m.remove("__kind");
}
let sf = photocraft_doc::SmartFilter {
command: format!("image.adjustments.{kind}"),
params,
blend: photocraft_color::BlendMode::Normal,
opacity: 1.0,
visible: true,
};
let sel = s.active().and_then(|d| d.doc.selection.clone());
s.edit(label, |doc, _| crate::smart_cmds::add_smart_filter(doc, id, sf, sel.as_ref()))?;
Ok(true)
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
@@ -273,6 +319,9 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<
}
fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
if adjust_as_smart_filter(s, "shadowsHighlights", "Shadows/Highlights", p)? {
return Ok(Value::Null);
}
let sh = shadows_highlights_params(p);
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
}
@@ -406,7 +455,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shadows/Highlights…",
["Image", "Adjustments"],
r##"{"shadowAmount":0..100=35,"shadowTone":0..100=50,"shadowRadius":0..2500=30,"highlightAmount":0..100=0,"highlightTone":0..100=50,"highlightRadius":0..2500=30,"color":-100..100=20,"midtone":-100..100=0,"blackClip":0..50=0.01,"whiteClip":0..50=0.01}"##,
has_pixels,
has_adjustable,
shadows_highlights
),
spec!(
+98
View File
@@ -356,6 +356,104 @@ fn invert_with_the_mask_targeted_inverts_the_mask() {
}
}
// ---------- Image › Adjustments as smart filters (FILE-215-4) ----------
/// A painted pixel layer converted to a smart object, over the painted Background.
fn smart_session() -> Session {
let mut s = session(8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
paint(&mut s, |x, y| [0.1 + x as f32 / 60.0, 0.2 + y as f32 / 50.0, 0.6 - x as f32 / 120.0, 1.0]);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s
}
fn flatten(s: &Session) -> Vec<[f32; 4]> {
photocraft_compose::flatten(doc(s)).px
}
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
fn smart_filters_of(s: &Session) -> Vec<photocraft_doc::SmartFilter> {
let d = s.active().unwrap();
match &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
LayerContent::Smart(sm) => sm.smart_filters.clone(),
other => panic!("not a smart object: {}", other.kind_name()),
}
}
/// FILE-215-4: Image › Adjustments on a smart object record a smart filter (Photoshop) instead
/// of baking into a copy of the pixels: it renders exactly what the destructive command would
/// bake in, and can be hidden, re-edited and undone, each in one history step.
#[test]
fn adjustments_on_a_smart_object_record_a_smart_filter() {
let mut s = smart_session();
let original = flatten(&s);
// The destructive result on the same pixels: the smart filter must match it exactly.
let mut plain = session(8, "rgb");
plain.execute("image.adjustments.invert", json!({})).unwrap();
let baked = flatten(&plain);
assert!(max_diff(&original, &baked) > 0.5, "the control changes the pixels");
assert!(s.is_enabled("image.adjustments.invert"), "an adjustment runs on a smart object");
let steps = s.active().unwrap().history.past_len();
s.execute("image.adjustments.invert", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step");
let sf = smart_filters_of(&s);
assert_eq!(sf.len(), 1);
assert_eq!(sf[0].command, "image.adjustments.invert");
assert!(sf[0].visible && sf[0].opacity == 1.0 && sf[0].blend == photocraft_color::BlendMode::Normal);
assert!(sf[0].params.get("__kind").is_none(), "the engine's private key is not recorded");
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "the smart filter renders the destructive result");
// Hiding the filter shows the original exactly.
s.execute("layer.smartFilter.setVisible", json!({"index": 0})).unwrap();
assert_eq!(flatten(&s), original);
s.undo();
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "undo restores the filtered pixels");
// Re-editing the recorded params re-renders; the same params give the same pixels.
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {}})).unwrap();
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "the same params render the same pixels");
// The setParams step, then the filter itself, undo away.
s.undo();
s.undo();
assert!(smart_filters_of(&s).is_empty(), "the filter is gone");
assert_eq!(flatten(&s), original);
}
/// A selection becomes the smart filter's mask, and the adjustment with its own command
/// (Shadows/Highlights) records a filter too.
#[test]
fn adjustment_smart_filters_take_a_selection_as_their_mask() {
let mut s = smart_session();
let original = flatten(&s);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 32})).unwrap();
s.execute("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0})).unwrap();
let d = s.active().unwrap();
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not smart") };
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, "image.adjustments.shadowsHighlights");
assert!(sm.filter_mask.is_some(), "the selection became the filter mask");
let now = flatten(&s);
let at = |px: &[[f32; 4]], x: i32, y: i32| px[(y * 48 + x) as usize];
assert!(changed(at(&original, 5, 16), at(&now, 5, 16)) > 1e-3, "inside the selection the shadows lifted");
assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged");
}
/// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments
/// that can't re-render from a source (Replace Color, Match Color) stay off a smart object.
#[test]
fn a_bad_adjustment_param_on_a_smart_object_is_an_error() {
let mut s = smart_session();
let (filters, steps, flat) = (smart_filters_of(&s).len(), s.active().unwrap().history.past_len(), flatten(&s));
let e = s.execute("image.adjustments.colorLookup", json!({"lut": 42})).unwrap_err();
assert!(e.to_string().contains("lut"), "{e}");
assert_eq!(smart_filters_of(&s).len(), filters, "nothing recorded");
assert_eq!(s.active().unwrap().history.past_len(), steps, "no history step");
assert_eq!(flatten(&s), flat, "pixels untouched");
assert!(!s.is_enabled("image.adjustments.replaceColor"), "only adjustments that re-render become smart filters");
assert!(!s.is_enabled("image.adjustments.matchColor"), "only adjustments that re-render become smart filters");
}
/// #780: a targeted mask enables Invert (and filters) on layers without pixels, such as an
/// adjustment layer; viewing the mask (⌥-click) targets it too.
#[test]
+96 -79
View File
@@ -245,45 +245,55 @@ fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) {
}
}
/// The document's own CMYK profile (absent, built-in or not a CMYK document: `None`, the
/// default conversions), which the stroke's colour conversions must honour — the compositors
/// and composite exports enter the same scope.
fn doc_cmyk_space(s: &Session) -> Option<std::sync::Arc<photocraft_color::convert::CmykSpace>> {
photocraft_color::convert::CmykSpace::for_profile(s.active().and_then(|d| d.doc.icc_profile.as_ref()))
}
/// Stroke with a resolved brush onto the target layer (pixels or mask).
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
let bg = s.tools.background;
let fg = brush.color;
let symmetry = s.active().and_then(|st| st.symmetry.clone());
let (id, brush, zoom) = stroke_target(s, p, brush)?;
let dmg = s.edit(label, |doc, _| {
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let mut brush = brush;
erase_locked(&mut brush, lock, bg);
if auto_erase {
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
}
let damage = if let Some(axis) = &symmetry {
let reflected = axis.reflected_passes(&pts);
let copies: Vec<_> = reflected
.iter()
.filter(|p| crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(&pts, p))
.map(|p| {
let mut renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
renderer.push(p);
renderer.finish();
renderer
})
.collect();
if copies.is_empty() {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
} else {
let pre = surf.clone();
let mut original = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
original.push(&pts);
original.finish();
original.composite_union_many(&copies, &pre, surf, sel.as_ref(), lock)
let cmyk = doc_cmyk_space(s);
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
s.edit(label, |doc, _| {
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let mut brush = brush;
erase_locked(&mut brush, lock, bg);
if auto_erase {
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
}
} else {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
};
Ok(damage)
let damage = if let Some(axis) = &symmetry {
let reflected = axis.reflected_passes(&pts);
let copies: Vec<_> = reflected
.iter()
.filter(|p| crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(&pts, p))
.map(|p| {
let mut renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
renderer.push(p);
renderer.finish();
renderer
})
.collect();
if copies.is_empty() {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
} else {
let pre = surf.clone();
let mut original = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
original.push(&pts);
original.finish();
original.composite_union_many(&copies, &pre, surf, sel.as_ref(), lock)
}
} else {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
};
Ok(damage)
})
})?;
Ok(damage_json(s, dmg))
}
@@ -353,6 +363,9 @@ pub struct LiveStroke {
params: Value,
/// Where the doc shows the stroke's end as finishing it would draw it (see `push`).
tail: Rect,
/// The document's CMYK profile the commit enters (`stroke_with`), so the preview shows the
/// pixels a `paint.stroke` with `seed` will write.
cmyk: Option<std::sync::Arc<photocraft_color::convert::CmykSpace>>,
}
impl LiveStroke {
@@ -401,6 +414,7 @@ impl LiveStroke {
layer,
params: p.clone(),
tail: Rect::EMPTY,
cmyk: doc_cmyk_space(s),
};
live.push(&pts)?;
Ok(live)
@@ -425,57 +439,60 @@ impl LiveStroke {
/// Invalid coordinates reject the whole batch without changing the preview.
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
check_coords(pts, &self.cmd)?;
self.renderer.push(pts);
if let Some(symmetry) = &self.symmetry {
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
*distinct |= crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(pts, &reflected);
mirror.push(&reflected);
let cmyk = self.cmyk.clone();
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
self.renderer.push(pts);
if let Some(symmetry) = &self.symmetry {
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
*distinct |= crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(pts, &reflected);
mirror.push(&reflected);
}
}
}
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
if self.mirror_distinct.iter().any(|d| *d) {
// Merge every finished pass once, both here and on commit, including shared axes.
let old = self.tail;
let mut original_preview = self.renderer.clone();
original_preview.finish();
let copies: Vec<_> = self
.mirrors
.iter()
.zip(&self.mirror_distinct)
.filter(|(_, d)| **d)
.map(|(r, _)| {
let mut r = r.clone();
r.finish();
r
})
.collect();
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
if self.symmetry_regions.is_empty() && !old.is_empty() {
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
if self.mirror_distinct.iter().any(|d| *d) {
// Merge every finished pass once, both here and on commit, including shared axes.
let old = self.tail;
let mut original_preview = self.renderer.clone();
original_preview.finish();
let copies: Vec<_> = self
.mirrors
.iter()
.zip(&self.mirror_distinct)
.filter(|(_, d)| **d)
.map(|(r, _)| {
let mut r = r.clone();
r.finish();
r
})
.collect();
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
if self.symmetry_regions.is_empty() && !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
for region in &self.symmetry_regions {
surf.write_region(*region, &self.pre.read_region(*region));
}
self.symmetry_regions =
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
self.tail = bounds;
return Ok(damage.union(&bounds));
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
if !old.is_empty() {
// Back to the stroke without the previous tail.
surf.write_region(old, &self.pre.read_region(old));
self.renderer.mark_dirty(old);
dmg = old;
}
for region in &self.symmetry_regions {
surf.write_region(*region, &self.pre.read_region(*region));
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
if let Some(mut tail) = self.renderer.tail_preview() {
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
dmg = dmg.union(&self.tail);
}
self.symmetry_regions =
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
self.tail = bounds;
return Ok(damage.union(&bounds));
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
if !old.is_empty() {
// Back to the stroke without the previous tail.
surf.write_region(old, &self.pre.read_region(old));
self.renderer.mark_dirty(old);
dmg = old;
}
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
if let Some(mut tail) = self.renderer.tail_preview() {
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
dmg = dmg.union(&self.tail);
}
Ok(dmg)
Ok(dmg)
})
}
}
+45
View File
@@ -695,3 +695,48 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]}));
}
#[test]
fn strokes_convert_through_the_documents_cmyk_profile() {
// A synthetic uncoated-like profile: heavier dot gain than the built-in coated CMYK (whose
// params are the defaults here), so painted ink differs from the default conversion. The
// compositors and composite exports enter the document's space; the stroke must too (#583).
let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
description: "Stroke Test Uncoated".into(),
tvi: [0.26, 0.26, 0.26, 0.3],
grid_a2b: 5,
grid_b2a: 9,
..Default::default()
})
.to_bytes();
let cmyk_session = |tagged: bool| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "mode": "cmyk", "background": "white"})).unwrap();
if tagged {
s.edit("icc", |doc, _| {
doc.icc_profile = Some(profile.clone());
Ok(())
})
.unwrap();
}
s
};
let params = json!({"points": [[4, 16], [28, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0});
let mut s = cmyk_session(true);
// The live preview enters the same space, so it commits the pixels it shows.
let mut live =
LiveStroke::begin(&s, &json!({"points": [[4, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0})).unwrap();
live.push(&[StrokePoint::new(28.0, 16.0, 1.0)]).unwrap();
let mut commit = params.clone();
commit["seed"] = json!(live.seed);
s.execute("paint.stroke", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 32, 32)), "the preview commits the pixels it shows");
// The stroke's ink differs from the same stroke without the embedded profile.
let ink = |surf: &Surface| surf.read_region(Rect::new(15, 16, 17, 18));
let tagged = ink(&surface(&s));
let mut plain = cmyk_session(false);
plain.execute("paint.stroke", params).unwrap();
let untagged = ink(&surface(&plain));
assert!(tagged != untagged, "the document's CMYK profile must convert the painted ink: {tagged:?} vs {untagged:?}");
}
+6 -1
View File
@@ -1093,7 +1093,7 @@ fn build() -> Vec<CommandSpec> {
_ => None,
},
params,
enabled: has_pixel_or_channel,
enabled: crate::adjust_cmds::has_adjustable,
run: |s, p| {
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
let eyedropped = if kind == "curves" { crate::adjust_params::curves_eyedropper_from_params(s, p)? } else { None };
@@ -1102,6 +1102,11 @@ fn build() -> Vec<CommandSpec> {
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
};
let label = adj.label().to_string();
// A smart object records the adjustment as a smart filter (Photoshop), which
// re-runs from the source instead of baking into a copy of the pixels.
if crate::adjust_cmds::adjust_as_smart_filter(s, &kind, &label, p)? {
return Ok(Value::Null);
}
destructive_adjust(s, &label, adj, p)
},
journal: true,
+2 -2
View File
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 19 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 148 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
@@ -101,7 +101,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded |
| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning |
| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection |
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | partial | [#215](https://github.com/storytold/photocraft/issues/215) | PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet |
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments.<kind> as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work |
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) |
+2 -2
View File
@@ -29,9 +29,9 @@ note = "smart_cmds.rs:382 mask only from the selection"
[[item]]
id = "FILE-215-4"
target = "Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters"
status = "partial"
status = "done"
issue = 215
note = "PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet"
note = "Image › Adjustments on a smart object records image.adjustments.<kind> as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work"
[[item]]
id = "FILE-215-5"