diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 3480c12ee..8b86f2c88 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -144,6 +144,131 @@ fn smart_objects_and_filters_survive_psd() { } } +#[test] +fn camera_raw_psd_filters_survive_re_editing_native_save_and_undo() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 50, "height": 40})).unwrap(); + s.execute( + "filter.cameraRaw", + json!({ + "exposure": 1.15, "contrast": -38, "highlights": 35, "shadows": -50, "whites": 49, "blacks": -34, + "curveDarks": -21, "curveLights": 38, "hslHue": [-50, 0, 29, 1, 0, 0, 0, 0], + "temperature": -26, "tint": -25, "texture": -31, "clarity": 30, "dehaze": -34, "vibrance": 33, "saturation": 33, + "sharpenAmount": 43, "sharpenRadius": 1.2, "sharpenDetail": 36, "sharpenMasking": 30, + "noiseLuminance": 47, "noiseLuminanceDetail": 50, "noiseColor": 25, "noiseColorDetail": 50, + "gradeShadows": {"hue": 117, "sat": 52, "lum": -24}, "gradeMidtones": {"hue": 51, "sat": 51, "lum": 41}, + "gradeHighlights": {"hue": 316, "sat": 70, "lum": 28}, "gradeGlobal": {"hue": 20, "sat": 61, "lum": 45}, + "gradeBlending": 36, "gradeBalance": 35, "grainAmount": 45, "grainSize": 25, "grainRoughness": 50, + "pointCurve": [[0, 0], [146, 102], [255, 255]], "pointCurveRed": [[0, 0], [90, 174], [255, 255]], + "pointCurveGreen": [[0, 0], [162, 122], [255, 255]], "pointCurveBlue": [[0, 0], [129, 202], [255, 255]] + }), + ) + .unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let out = photocraft_io::export(&before, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert!(structure_errors(&out.bytes).is_empty()); + let back = photocraft_io::import("camera-raw.psd", &out.bytes).unwrap().document; + let (layer, imported) = smart(&back); + let (_, original) = smart(&before); + let mask_values = |sm: &SmartObject| { + let mut values = Vec::new(); + sm.filter_mask.as_ref().unwrap().values_into(before.bounds(), &mut values); + values + }; + assert_eq!(imported.smart_filters.len(), 3); + assert_eq!(imported.smart_filters[1].command, "filter.cameraRaw"); + assert_eq!(mask_values(&imported), mask_values(&original)); + let expected = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &original.smart_filters[1].params).unwrap(); + let actual = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &imported.smart_filters[1].params).unwrap(); + assert_eq!(actual, expected, "{depth}-bit"); + let template = imported.smart_filters[1].params["__cameraRawPsd"].clone(); + let mut edited = Session::new(); + edited.add_document(back, None); + edited.execute("layer.smartFilter.setParams", json!({"layer": layer, "index": 1, "params": {"exposure": 0, "curveLights": 0}})).unwrap(); + assert_eq!(smart(&edited.active().unwrap().doc).1.smart_filters[1].params["__cameraRawPsd"], template); + let changed = edited.active().unwrap().doc.clone(); + edited.undo(); + assert_eq!(smart(&edited.active().unwrap().doc).1.smart_filters[1].params["exposure"], imported.smart_filters[1].params["exposure"]); + edited.redo(); + assert_eq!(*edited.active().unwrap().doc, *changed); + let native = photocraft_format::save_to_bytes(&changed, &Default::default()).unwrap(); + let native_back = photocraft_format::load_from_bytes(&native).unwrap(); + assert_eq!(smart(&native_back).1.smart_filters, smart(&changed).1.smart_filters); + let out = photocraft_io::export(&native_back, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert!(structure_errors(&out.bytes).is_empty()); + let back = photocraft_io::import("edited.psd", &out.bytes).unwrap().document; + let (_, after) = smart(&back); + assert_eq!(after.smart_filters.len(), 3); + let params = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &after.smart_filters[1].params).unwrap(); + assert_eq!((params.exposure, params.curve_lights), (0.0, 0.0)); + assert_eq!(params.hsl_hue, expected.hsl_hue); + assert_eq!(mask_values(&after), mask_values(&original)); + let mut unfiltered = after.clone(); + unfiltered.smart_filters.clear(); + assert!(photocraft_engine::smart_cmds::render(&back, &unfiltered).unwrap().is_some()); + } +} + +/// Private, user-supplied files remain outside the repository. Set PHOTOCRAFT_CAMERA_RAW_PSD +/// to check a Camera Raw fixture and PHOTOCRAFT_SMART_PSD_OUT to retain exports for manual checks. +#[test] +#[ignore = "requires a user-supplied Camera Raw PSD"] +fn camera_raw_psd_fixture() { + let path = std::env::var("PHOTOCRAFT_CAMERA_RAW_PSD").expect("set PHOTOCRAFT_CAMERA_RAW_PSD"); + let bytes = std::fs::read(&path).unwrap(); + let imported = photocraft_io::import("fixture.psd", &bytes).unwrap(); + let (_, sm) = smart(&imported.document); + let index = sm + .smart_filters + .iter() + .position(|f| { + photocraft_io::smart_map::item_for_filter(f).ok().is_some_and( + |item| matches!(item.get("Fltr"), Some(photocraft_psd::descriptor::Value::Descriptor(d)) if d.class_id.is("Adobe Camera Raw Filter")), + ) + }) + .expect("a Camera Raw filter"); + let original_item = photocraft_io::smart_map::item_for_filter(&sm.smart_filters[index]).unwrap(); + let out = photocraft_io::export(&imported.document, "psd", &Default::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("smart filter")), "{:?}", out.warnings); + assert!(structure_errors(&out.bytes).is_empty()); + keep("camera-raw-roundtrip.psd", &out.bytes); + let back = photocraft_io::import("roundtrip.psd", &out.bytes).unwrap().document; + let (layer, _) = smart(&back); + assert_eq!(photocraft_io::smart_map::item_for_filter(&smart(&back).1.smart_filters[index]).unwrap(), original_item); + let native = photocraft_format::save_to_bytes(&back, &Default::default()).unwrap(); + assert_eq!(smart(&photocraft_format::load_from_bytes(&native).unwrap()).1.smart_filters, smart(&back).1.smart_filters); + if sm.smart_filters[index].command == photocraft_io::smart_map::UNSUPPORTED_FILTER { + assert_eq!(smart(&back).1.smart_filters[index].command, photocraft_io::smart_map::UNSUPPORTED_FILTER); + println!("Camera Raw settings survive PSD and native saves; active unmapped controls keep this filter opaque"); + return; + } + let mut s = Session::new(); + s.add_document(back, None); + s.execute("layer.smartFilter.setParams", json!({"layer": layer, "index": index, "params": {"exposure": 0.5}})).unwrap(); + let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("smart filter")), "{:?}", out.warnings); + assert!(structure_errors(&out.bytes).is_empty()); + keep("camera-raw-edited.psd", &out.bytes); + let back = photocraft_io::import("edited.psd", &out.bytes).unwrap().document; + let after = &smart(&back).1.smart_filters[index]; + assert_eq!(after.params["exposure"], 0.5); + let mut expected = original_item; + let mut fltr = match expected.get("Fltr").unwrap() { + photocraft_psd::descriptor::Value::Descriptor(d) => d.clone(), + _ => panic!("Fltr must be a descriptor"), + }; + fltr.items.iter_mut().find(|(key, _)| key.is("Ex12")).unwrap().1 = photocraft_psd::descriptor::Value::Double(0.5); + expected.items.iter_mut().find(|(key, _)| key.is("Fltr")).unwrap().1 = photocraft_psd::descriptor::Value::Descriptor(fltr); + assert_eq!(photocraft_io::smart_map::item_for_filter(after).unwrap(), expected, "only Exposure changes"); +} + /// Every colour model: the smart object, its filters and the filter cache's unfiltered pixels /// (in the document's model, CMYK inverted and 16-bit Lab scaled as in layer channels) survive. #[test] diff --git a/crates/io/src/camera_raw_map.rs b/crates/io/src/camera_raw_map.rs new file mode 100644 index 000000000..cfe3ad6be --- /dev/null +++ b/crates/io/src/camera_raw_map.rs @@ -0,0 +1,671 @@ +//! Camera Raw Filter descriptors observed in real PSD files (filter version 18.4, process 6). +//! Verified develop controls are editable. Other processing versions +//! or settings retain the entire filter through the existing opaque-filter path. +use photocraft_algo::camera_raw::CameraRaw; +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, int, rgbc, set, to_hex}; + +pub(super) const CLASS: &str = "Adobe Camera Raw Filter"; +pub(super) const COMMAND: &str = "filter.cameraRaw"; +const FILTER_ID: i32 = 2783; +const TEMPLATE_KEY: &str = "__cameraRawPsd"; +const MAX_TEMPLATE_BYTES: usize = 1024 * 1024; + +// Exposure and sharpening radius are doubles; other observed controls are integer levels. +const SCALARS: &[(&str, &str)] = &[ + ("temperature", "Temp"), + ("tint", "Tint"), + ("exposure", "Ex12"), + ("contrast", "Cr12"), + ("highlights", "Hi12"), + ("shadows", "Sh12"), + ("whites", "Wh12"), + ("blacks", "Bk12"), + ("texture", "CrTx"), + ("clarity", "Cl12"), + ("dehaze", "Dhze"), + ("vibrance", "Vibr"), + ("saturation", "Strt"), + ("curveShadows", "PC_S"), + ("curveDarks", "PC_D"), + ("curveLights", "PC_L"), + ("curveHighlights", "PC_H"), + ("sharpenAmount", "Shrp"), + ("sharpenRadius", "ShpR"), + ("sharpenDetail", "ShpD"), + ("sharpenMasking", "ShpM"), + ("noiseLuminance", "LNR "), + ("noiseLuminanceDetail", "LNRD"), + ("noiseColor", "CNR "), + ("noiseColorDetail", "CNRD"), + ("gradeShadows.hue", "STSH"), + ("gradeShadows.sat", "STSS"), + ("gradeShadows.lum", "CgSL"), + ("gradeMidtones.hue", "CgMH"), + ("gradeMidtones.sat", "CgMS"), + ("gradeMidtones.lum", "CgML"), + ("gradeHighlights.hue", "STHH"), + ("gradeHighlights.sat", "STHS"), + ("gradeHighlights.lum", "CgHL"), + ("gradeGlobal.hue", "CgGH"), + ("gradeGlobal.sat", "CgGS"), + ("gradeGlobal.lum", "CgGL"), + ("gradeBlending", "CgBl"), + ("gradeBalance", "STB "), + ("grainAmount", "GRNA"), + ("grainSize", "GRNS"), + ("grainRoughness", "GRNF"), + ("vignetteAmount", "PCVA"), + ("vignetteMidpoint", "PCVM"), + ("vignetteFeather", "PCVF"), + ("vignetteRoundness", "PCVR"), +]; +const ARRAYS: &[(&str, &[&str])] = &[ + ("curveSplits", &["PC_1", "PC_2", "PC_3"]), + ("hslHue", &["HA_R", "HA_O", "HA_Y", "HA_G", "HA_A", "HA_B", "HA_P", "HA_M"]), + ("hslSat", &["SA_R", "SA_O", "SA_Y", "SA_G", "SA_A", "SA_B", "SA_P", "SA_M"]), + ("hslLum", &["LA_R", "LA_O", "LA_Y", "LA_G", "LA_A", "LA_B", "LA_P", "LA_M"]), +]; +const CURVES: &[(&str, &str)] = &[("pointCurve", "Crv "), ("pointCurveRed", "CrvR"), ("pointCurveGreen", "CrvG"), ("pointCurveBlue", "CrvB")]; + +// These controls have no PhotoCraft counterpart yet. Their observed neutral values can be +// retained in the template; active values must keep the entire filter opaque. +const NEUTRAL_ONLY: &[(&str, i32)] = &[("LNRC", 0), ("CNRS", 50), ("MDis", 0), ("VigA", 0), ("VigM", 50), ("crfs", 100), ("TMMs", 0), ("PGTM", 0), ("RGBt", 0)]; + +fn default_params() -> Option { + serde_json::to_value(CameraRaw::default()).ok() +} + +fn known_key(key: &str) -> bool { + matches!(key, "CrVe" | "PrVN" | "PrVe" | "WBal") + || SCALARS.iter().any(|(_, k)| *k == key) + || ARRAYS.iter().any(|(_, keys)| keys.contains(&key)) + || CURVES.iter().any(|(_, k)| *k == key) + || NEUTRAL_ONLY.iter().any(|(k, _)| *k == key) +} + +fn get_param<'a>(params: &'a J, name: &str) -> Option<&'a J> { + if let Some((wheel, field)) = name.split_once('.') { params.get(wheel)?.get(field) } else { params.get(name) } +} + +fn get_param_mut<'a>(params: &'a mut J, name: &str) -> Option<&'a mut J> { + if let Some((wheel, field)) = name.split_once('.') { params.get_mut(wheel)?.get_mut(field) } else { params.get_mut(name) } +} + +fn number_range(key: &str) -> std::ops::RangeInclusive { + match key { + "Ex12" => -5.0..=5.0, + "ShpR" => 0.5..=3.0, + "Shrp" => 0.0..=150.0, + "STSH" | "STHH" | "CgMH" | "CgGH" => 0.0..=360.0, + "PC_1" | "PC_2" | "PC_3" | "ShpD" | "ShpM" | "LNR " | "LNRD" | "CNR " | "CNRD" | "STSS" | "STHS" | "CgMS" | "CgGS" | "CgBl" | "GRNA" | "GRNS" + | "GRNF" | "PCVM" | "PCVF" => 0.0..=100.0, + _ => -100.0..=100.0, + } +} + +fn read_number(d: &Descriptor, key: &str) -> Option { + let value = match (key, d.get(key)?) { + ("Ex12" | "ShpR", Value::Double(value)) => *value, + (_, Value::Integer(value)) if !matches!(key, "Ex12" | "ShpR") => f64::from(*value), + _ => return None, + }; + (value.is_finite() && number_range(key).contains(&value)).then_some(value) +} + +fn read_curve(value: &Value) -> Option { + let Value::List(values) = value else { return None }; + if values.len() < 4 || !values.len().is_multiple_of(2) || values.len() > photocraft_algo::camera_raw::MAX_CURVE_POINTS * 2 { + return None; + } + let points: Option> = values + .as_chunks::<2>() + .0 + .iter() + .map(|[x, y]| { + let (Value::Integer(x), Value::Integer(y)) = (x, y) else { return None }; + Some([*x as f32, *y as f32]) + }) + .collect(); + let points = points?; + photocraft_algo::camera_raw::validate_curve(&points).ok()?; + if points == [[0.0, 0.0], [255.0, 255.0]] { + return Some(json!([])); + } + serde_json::to_value(points).ok() +} + +fn descriptor_params(d: &Descriptor) -> Option { + if !d.class_id.is(CLASS) + || !matches!((int(d, "PrVN"), int(d, "PrVe")), (Some(5), Some(184549376)) | (Some(6), Some(251920384))) + || !matches!(d.get("CrVe"), Some(Value::Text(_))) + { + return None; + } + // A partial decode would omit active processing (masks, profiles, HDR or other settings) + // when the layer is re-rendered. Keep such a filter opaque instead. + if d.items.iter().any(|(key, _)| !known_key(&String::from_utf8_lossy(key.as_bytes()))) { + return None; + } + if d.get("WBal").is_some_and(|v| !matches!(v, Value::Enumerated { type_id, value } if type_id.is("WBal") && value.is("Cst "))) + || NEUTRAL_ONLY.iter().any(|(key, neutral)| d.get(key).is_some() && int(d, key) != Some(*neutral)) + { + return None; + } + let mut params = default_params()?; + for (param, key) in SCALARS { + if d.get(key).is_some() { + *get_param_mut(&mut params, param)? = json!(read_number(d, key)?); + } + } + for (param, key) in CURVES { + if let Some(value) = d.get(key) { + *params.get_mut(*param)? = read_curve(value)?; + } + } + for (param, keys) in ARRAYS { + for (index, key) in keys.iter().enumerate() { + if d.get(key).is_some() { + *params.get_mut(*param)?.get_mut(index)? = json!(read_number(d, key)?); + } + } + } + validate_splits(¶ms).ok()?; + // Use the same f32 representation as the editor so opening and confirming without changes + // does not rewrite a stored double such as 1.15 to 1.149999976158142. + let typed: CameraRaw = serde_json::from_value(params).ok()?; + serde_json::to_value(typed).ok() +} + +pub(super) fn import_params(item: &Descriptor) -> Option { + if int(item, "filterID") != Some(FILTER_ID) { + return None; + } + let mut params = descriptor_params(get_desc(item, "Fltr")?)?; + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + let bytes = template.to_bytes(); + if bytes.len() > MAX_TEMPLATE_BYTES { + return None; + } + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&bytes))); + Some(params) +} + +fn validate_splits(params: &J) -> Result<(), String> { + let values = params.get("curveSplits").and_then(J::as_array).ok_or("curveSplits must contain three numbers")?; + if values.len() != 3 { + return Err("curveSplits must contain three numbers".into()); + } + let mut previous = -1.0; + for value in values { + let value = value.as_f64().filter(|v| v.is_finite() && (0.0..=100.0).contains(v)).ok_or("curve splits must be finite percentages")?; + if value <= previous { + return Err("curve splits must be strictly increasing".into()); + } + previous = value; + } + Ok(()) +} + +fn supported_param(key: &str) -> bool { + SCALARS.iter().any(|(p, _)| p.split('.').next() == Some(key)) || ARRAYS.iter().any(|(p, _)| *p == key) || CURVES.iter().any(|(p, _)| *p == key) +} + +fn write_number(d: &mut Descriptor, key: &str, value: &J) -> Result<(), String> { + let number = value.as_f64().filter(|v| v.is_finite()).ok_or_else(|| format!("Camera Raw {key} must be finite"))?; + if !number_range(key).contains(&number) { + return Err(format!("Camera Raw {key} is outside the supported range")); + } + // PSD stores these as integer slider levels; PhotoCraft's sliders are continuous. Rounding + // (at most half a level) keeps the filter instead of leaving it out of the PSD. + // Hue 360 is the same angle as 0. + let value = match key { + "Ex12" | "ShpR" => Value::Double(number), + "STSH" | "STHH" | "CgMH" | "CgGH" => Value::Integer(number.round() as i32 % 360), + _ => Value::Integer(number.round() as i32), + }; + set(d, key, value); + Ok(()) +} + +pub(super) fn export_item(params: &J) -> Result { + let object = params.as_object().ok_or("Camera Raw parameters must be an object")?; + let defaults = default_params().ok_or("cannot encode Camera Raw defaults")?; + for key in object.keys() { + if key != "layer" && !key.starts_with("__") && defaults.get(key).is_none() { + return Err(format!("Camera Raw setting `{key}` has no verified PSD mapping")); + } + } + for wheel in ["gradeShadows", "gradeMidtones", "gradeHighlights", "gradeGlobal"] { + if params.get(wheel).and_then(J::as_object).is_some_and(|v| v.keys().any(|k| !matches!(k.as_str(), "hue" | "sat" | "lum"))) { + return Err(format!("Camera Raw `{wheel}` contains an unknown wheel setting")); + } + } + // Bound variable-sized curves before deserialization allocates their point arrays. + for (param, _) in CURVES { + if params.get(*param).and_then(J::as_array).is_some_and(|v| v.len() > photocraft_algo::camera_raw::MAX_CURVE_POINTS) { + return Err(format!("Camera Raw `{param}` has too many points")); + } + } + let mut item = if let Some(raw) = object.get(TEMPLATE_KEY) { + let raw = raw.as_str().ok_or("Camera Raw PSD template must be hex text")?; + if raw.len() > MAX_TEMPLATE_BYTES * 2 { + return Err("Camera Raw PSD template is too large".into()); + } + let bytes = from_hex(raw).ok_or("Camera Raw PSD template is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("bad Camera Raw PSD template: {e}"))?; + if !item.class_id.is("filterFX") || int(&item, "filterID") != Some(FILTER_ID) || get_desc(&item, "Fltr").and_then(descriptor_params).is_none() { + return Err("Camera Raw PSD template is not a supported Camera Raw filter".into()); + } + item + } else { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Camera Raw Filter"))) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with( + "Fltr", + Value::Descriptor( + Descriptor::new(CLASS) + .with("CrVe", Value::Text(UnicodeString::new_nul("18.4"))) + .with("PrVN", Value::Integer(6)) + .with("PrVe", Value::Integer(251920384)), + ), + ) + .with("filterID", Value::Integer(FILTER_ID)) + }; + let typed: CameraRaw = serde_json::from_value(params.clone()).map_err(|e| format!("bad Camera Raw parameters: {e}"))?; + typed.validate()?; + let normalized = serde_json::to_value(typed).map_err(|e| e.to_string())?; + for (key, value) in normalized.as_object().ok_or("cannot encode Camera Raw parameters")? { + if !supported_param(key) && defaults.get(key) != Some(value) { + return Err(format!("Camera Raw setting `{key}` has no verified PSD mapping")); + } + } + validate_splits(&normalized)?; + let mut d = get_desc(&item, "Fltr").cloned().ok_or("Camera Raw PSD template has no filter settings")?; + let original = descriptor_params(&d).ok_or("unsupported Camera Raw processing version or settings")?; + for (param, key) in SCALARS { + let value = get_param(&normalized, param).ok_or("missing Camera Raw parameter")?; + if get_param(&original, param) != Some(value) { + write_number(&mut d, key, value)?; + if matches!(*key, "Temp" | "Tint") { + set( + &mut d, + "WBal", + Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("WBal"), value: photocraft_psd::descriptor::Id::new("Cst ") }, + ); + } + } + } + for (param, key) in CURVES { + let value = normalized.get(*param).ok_or("missing Camera Raw curve")?; + if original.get(*param) != Some(value) { + let points = value.as_array().ok_or("Camera Raw curve must be an array")?; + let mut levels: Vec<[f32; 2]> = Vec::new(); + for point in points { + let mut level = [0.0; 2]; + let coordinates = point.as_array().filter(|c| c.len() == 2).ok_or("Camera Raw curve point must contain two coordinates")?; + for (out, coordinate) in level.iter_mut().zip(coordinates) { + let number = coordinate + .as_f64() + .filter(|v| v.is_finite() && (0.0..=255.0).contains(v)) + .ok_or("Camera Raw curve coordinates must be levels 0–255")?; + *out = number.round() as f32; + } + levels.push(level); + } + if levels.is_empty() { + levels = vec![[0.0, 0.0], [255.0, 255.0]]; + } + photocraft_algo::camera_raw::validate_curve(&levels).map_err(|e| format!("Camera Raw `{param}`: {e}"))?; + let values = levels.iter().flatten().map(|v| Value::Integer(*v as i32)).collect(); + set(&mut d, key, Value::List(values)); + } + } + for (param, keys) in ARRAYS { + for (index, key) in keys.iter().enumerate() { + let value = normalized.get(*param).and_then(|v| v.get(index)).ok_or("missing Camera Raw array parameter")?; + if original.get(*param).and_then(|v| v.get(index)) != Some(value) { + write_number(&mut d, key, value)?; + } + } + } + set(&mut item, "Fltr", Value::Descriptor(d)); + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter}; + use photocraft_color::BlendMode; + + // Parameter values observed in the supplied 18.4 PSD. No source pixels or private + // document metadata are included in this synthetic regression fixture. + fn source_item() -> Descriptor { + let mut d = Descriptor::new(CLASS) + .with("CrVe", Value::Text(UnicodeString::new_nul("18.4"))) + .with("PrVN", Value::Integer(6)) + .with("PrVe", Value::Integer(251920384)) + .with("Ex12", Value::Double(1.15)); + for (key, value) in [ + ("Cr12", -38), + ("Hi12", 35), + ("Sh12", -50), + ("Wh12", 49), + ("Bk12", -34), + ("PC_S", 0), + ("PC_D", -21), + ("PC_L", 38), + ("PC_H", 0), + ("PC_1", 25), + ("PC_2", 50), + ("PC_3", 75), + ] { + d = d.with(key, Value::Integer(value)); + } + for (_, keys) in ARRAYS.iter().skip(1) { + for key in *keys { + let value = match *key { + "HA_R" => -50, + "HA_Y" => 29, + "HA_G" => 1, + _ => 0, + }; + d = d.with(key, Value::Integer(value)); + } + } + let f = SmartFilter { command: COMMAND.into(), params: J::Null, blend: BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Camera Raw Filter"))) + .with("blendOptions", blend_options(&f)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(d)) + .with("filterID", Value::Integer(FILTER_ID)) + } + + fn all_controls_item() -> Descriptor { + let mut item = source_item(); + let mut d = get_desc(&item, "Fltr").unwrap().clone(); + set(&mut d, "WBal", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("WBal"), value: photocraft_psd::descriptor::Id::new("Cst ") }); + set(&mut d, "ShpR", Value::Double(1.2)); + for (key, value) in [ + ("Temp", -26), + ("Tint", -25), + ("CrTx", -31), + ("Cl12", 30), + ("Dhze", -34), + ("Vibr", 33), + ("Strt", 33), + ("Shrp", 43), + ("ShpD", 36), + ("ShpM", 30), + ("LNR ", 47), + ("LNRD", 50), + ("CNR ", 25), + ("CNRD", 50), + ("STSH", 117), + ("STSS", 52), + ("STHH", 316), + ("STHS", 70), + ("STB ", 35), + ("CgMH", 51), + ("CgMS", 51), + ("CgSL", -24), + ("CgML", 41), + ("CgHL", 28), + ("CgBl", 36), + ("CgGH", 20), + ("CgGS", 61), + ("CgGL", 45), + ("GRNA", 45), + ("GRNS", 25), + ("GRNF", 50), + ("PCVA", 31), + ("PCVM", 50), + ("PCVF", 50), + ("PCVR", 0), + ] { + set(&mut d, key, Value::Integer(value)); + } + for (key, neutral) in NEUTRAL_ONLY { + set(&mut d, key, Value::Integer(*neutral)); + } + for (key, x, y) in [("Crv ", 146, 102), ("CrvR", 90, 174), ("CrvG", 162, 122), ("CrvB", 129, 202)] { + set(&mut d, key, Value::List([0, 0, x, y, 255, 255].into_iter().map(Value::Integer).collect())); + } + set(&mut item, "Fltr", Value::Descriptor(d)); + item + } + + #[test] + fn all_observed_develop_controls_roundtrip_and_patch_only_the_edited_field() { + let source = all_controls_item(); + let f = filter_from_item(&source); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["temperature"], -26.0); + assert_eq!(f.params["texture"], -31.0); + assert!((f.params["sharpenRadius"].as_f64().unwrap() - 1.2).abs() < 1e-6); + assert_eq!(f.params["gradeHighlights"], json!({"hue": 316.0, "sat": 70.0, "lum": 28.0})); + assert_eq!(f.params["pointCurveBlue"], json!([[0.0, 0.0], [129.0, 202.0], [255.0, 255.0]])); + assert_eq!(item_for_filter(&f).unwrap(), source); + let defaults = default_params().unwrap(); + for (param, key) in SCALARS { + let mut edited = f.clone(); + let mut number = get_param(&defaults, param).unwrap().as_f64().unwrap(); + if get_param(&f.params, param).unwrap().as_f64() == Some(number) { + number += 1.0; + } + *get_param_mut(&mut edited.params, param).unwrap() = json!(number); + let mut expected = get_desc(&source, "Fltr").unwrap().clone(); + set(&mut expected, key, if matches!(*key, "Ex12" | "ShpR") { Value::Double(number) } else { Value::Integer(number as i32) }); + assert_eq!(get_desc(&item_for_filter(&edited).unwrap(), "Fltr"), Some(&expected), "{param}"); + } + for (param, key) in CURVES { + let mut edited = f.clone(); + edited.params[*param] = json!([]); + let item = item_for_filter(&edited).unwrap(); + let mut expected = get_desc(&source, "Fltr").unwrap().clone(); + set(&mut expected, key, Value::List([0, 0, 255, 255].into_iter().map(Value::Integer).collect())); + assert_eq!(get_desc(&item, "Fltr"), Some(&expected), "{param}"); + assert_eq!(filter_from_item(&item).params[*param], json!([]), "{param}"); + } + } + + #[test] + fn active_unimplemented_controls_and_malformed_curves_stay_opaque() { + for (key, neutral) in NEUTRAL_ONLY { + let mut item = all_controls_item(); + let mut d = get_desc(&item, "Fltr").unwrap().clone(); + set(&mut d, key, Value::Integer(neutral + 1)); + set(&mut item, "Fltr", Value::Descriptor(d)); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{key}"); + assert_eq!(item_for_filter(&f).unwrap(), item, "{key}"); + } + for value in [ + Value::List(vec![Value::Integer(0)]), + Value::List([0, 0, 0, 255].into_iter().map(Value::Integer).collect()), + Value::List([0, 0, 255, 256].into_iter().map(Value::Integer).collect()), + Value::List(vec![Value::Double(0.0); 4]), + Value::List(vec![Value::Integer(0); 34]), + ] { + let mut item = all_controls_item(); + let mut d = get_desc(&item, "Fltr").unwrap().clone(); + set(&mut d, "Crv ", value); + set(&mut item, "Fltr", Value::Descriptor(d)); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + #[ignore = "requires a user-supplied Camera Raw PSD"] + fn all_sliders_fixture() { + let path = std::env::var("PHOTOCRAFT_CAMERA_RAW_PSD").expect("set PHOTOCRAFT_CAMERA_RAW_PSD"); + let doc = crate::import("fixture.psd", &std::fs::read(path).unwrap()).unwrap().document; + let original = doc + .walk() + .into_iter() + .find_map(|(_, _, layer)| { + let photocraft_doc::LayerContent::Smart(sm) = &layer.content else { return None }; + sm.smart_filters.iter().find_map(|f| { + let item = item_for_filter(f).ok()?; + get_desc(&item, "Fltr").is_some_and(|d| d.class_id.is(CLASS)).then_some(item) + }) + }) + .expect("a Camera Raw descriptor"); + assert_eq!(item_for_filter(&filter_from_item(&original)).unwrap(), original); + let mut projected = original.clone(); + let mut d = get_desc(&original, "Fltr").unwrap().clone(); + // This is a codec projection, not an editable replacement for the source filter: + // active Optics settings have no rendering counterpart, and PCVS is unverified. + d.items.retain(|(key, _)| !["MDis", "VigA", "PCVS"].iter().any(|k| key.is(k))); + set(&mut projected, "Fltr", Value::Descriptor(d)); + let mut f = filter_from_item(&projected); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["temperature"], -26.0); + assert_eq!(f.params["sharpenAmount"], 43.0); + assert_eq!(f.params["gradeGlobal"], json!({"hue": 20.0, "sat": 61.0, "lum": 45.0})); + assert_eq!(f.params["pointCurveRed"], json!([[0.0, 0.0], [90.0, 174.0], [255.0, 255.0]])); + assert_eq!(item_for_filter(&f).unwrap(), projected); + f.params["exposure"] = json!(0.5); + let mut expected = get_desc(&projected, "Fltr").unwrap().clone(); + set(&mut expected, "Ex12", Value::Double(0.5)); + assert_eq!(get_desc(&item_for_filter(&f).unwrap(), "Fltr"), Some(&expected)); + } + + #[test] + fn light_curve_and_hsl_are_editable_without_rewriting_unchanged_settings() { + let source = source_item().with("futureOuterData", Value::RawData(vec![1, 2, 3, 4])); + let mut f = filter_from_item(&source); + assert_eq!(f.command, COMMAND); + assert!((f.params["exposure"].as_f64().unwrap() - 1.15).abs() < 1e-6); + assert_eq!(f.params["contrast"], -38.0); + assert_eq!(f.params["curveDarks"], -21.0); + assert_eq!(f.params["curveLights"], 38.0); + assert_eq!(f.params["curveSplits"], json!([25.0, 50.0, 75.0])); + assert_eq!(f.params["hslHue"], json!([-50.0, 0.0, 29.0, 1.0, 0.0, 0.0, 0.0, 0.0])); + assert_eq!(item_for_filter(&f).unwrap(), source); + f.params["exposure"] = json!(0.0); + f.params["hslHue"][2] = json!(-20.0); + f.visible = false; + f.opacity = 0.4; + f.blend = BlendMode::Multiply; + let edited = item_for_filter(&f).unwrap(); + let mut expected = get_desc(&source, "Fltr").unwrap().clone(); + set(&mut expected, "Ex12", Value::Double(0.0)); + set(&mut expected, "HA_Y", Value::Integer(-20)); + assert_eq!(get_desc(&edited, "Fltr"), Some(&expected)); + assert_eq!(edited.get("futureOuterData"), source.get("futureOuterData")); + let back = filter_from_item(&edited); + assert_eq!((back.visible, back.blend, back.opacity), (false, BlendMode::Multiply, 0.4)); + assert_eq!(back.params["exposure"], 0.0); + assert_eq!(back.params["hslHue"][2], -20.0); + } + + #[test] + fn new_filters_write_every_verified_control_and_accept_explicit_defaults() { + let mut params = default_params().unwrap(); + params["seed"] = json!(0); + for (param, key) in SCALARS { + let number = match *key { + "Ex12" => 0.75, + "ShpR" => 1.2, + _ => 12.0, + }; + *get_param_mut(&mut params, param).unwrap() = json!(number); + } + for (param, keys) in ARRAYS { + params[*param] = if *param == "curveSplits" { json!([20, 45, 80]) } else { json!((0..keys.len()).map(|i| i as f64 + 1.0).collect::>()) }; + } + let f = SmartFilter { command: COMMAND.into(), params: params.clone(), blend: BlendMode::Screen, opacity: 0.5, visible: false }; + let item = item_for_filter(&f).unwrap(); + let back = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((&back.command, back.visible, back.blend, back.opacity), (&f.command, false, BlendMode::Screen, 0.5)); + // Compare slider numbers after both sides have been normalized through CameraRaw. + let typed: CameraRaw = serde_json::from_value(params).unwrap(); + let expected = serde_json::to_value(typed).unwrap(); + for param in SCALARS.iter().map(|(p, _)| *p).chain(ARRAYS.iter().map(|(p, _)| *p)) { + assert_eq!(get_param(&back.params, param), get_param(&expected, param)); + } + } + + #[test] + fn unsupported_processing_settings_remain_opaque_and_lossless() { + for (key, value) in [ + ("LCs ", Value::Text(UnicodeString::new("unknown masks"))), + ("PrVN", Value::Integer(7)), + ("PrVe", Value::Integer(123)), + ("Ex12", Value::Double(f64::INFINITY)), + ("Cr12", Value::Text(UnicodeString::new("bad type"))), + ] { + let mut item = source_item(); + let mut d = get_desc(&item, "Fltr").unwrap().clone(); + set(&mut d, key, value); + set(&mut item, "Fltr", Value::Descriptor(d)); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{key}"); + assert_eq!(item_for_filter(&f).unwrap(), item, "{key}"); + } + } + + #[test] + fn fractional_slider_levels_round_instead_of_dropping_the_filter() { + let mut f = filter_from_item(&source_item()); + f.params["contrast"] = json!(-12.4); + f.params["hslHue"][0] = json!(10.5); + f.params["gradeShadows"]["hue"] = json!(359.7); + f.params["pointCurve"] = json!([[0, 0], [100.2, 120.6], [200.6, 180.4], [255, 255]]); + let item = item_for_filter(&f).unwrap(); + let d = get_desc(&item, "Fltr").unwrap(); + assert_eq!(d.get("Cr12"), Some(&Value::Integer(-12))); + assert_eq!(d.get("HA_R"), Some(&Value::Integer(11))); + assert_eq!(d.get("STSH"), Some(&Value::Integer(0))); + assert_eq!(d.get("Crv "), Some(&Value::List([0, 0, 100, 121, 201, 180, 255, 255].into_iter().map(Value::Integer).collect()))); + let back = filter_from_item(&item); + assert_eq!(back.command, COMMAND); + assert_eq!(back.params["contrast"], -12.0); + } + + #[test] + fn unsupported_or_malformed_exports_report_errors() { + for params in [ + J::Null, + json!({"exposure": "bright"}), + json!({"exposure": 1e40}), + json!({"exposure": 6}), + json!({"contrast": 101}), + json!({"hslHue": [1, 2]}), + json!({"curveSplits": [80, 50, 20]}), + json!({"curveSplits": [25, 25, 75]}), + json!({"pointCurve": [[0, 0], [0, 255]]}), + json!({"sharpenRadius": 4}), + json!({"gradeShadows": {"hue": 361}}), + json!({"gradeShadows": {"unknown": 1}}), + json!({"seed": 1}), + json!({"vignetteHighlights": 10}), + json!({"futureSetting": 1}), + json!({TEMPLATE_KEY: "zz"}), + json!({TEMPLATE_KEY: "00"}), + json!({TEMPLATE_KEY: 12}), + json!({TEMPLATE_KEY: "0".repeat(MAX_TEMPLATE_BYTES * 2 + 1)}), + ] { + assert!(export_item(¶ms).is_err()); + } + let wrong = Descriptor::new("filterFX").with("filterID", Value::Integer(1)); + assert!(export_item(&json!({TEMPLATE_KEY: to_hex(&wrong.to_bytes())})).is_err()); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index b4ea8b72d..d35e67d27 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -32,6 +32,9 @@ use serde_json::{Map, Value as J, json}; use crate::blocks::{enum_of, get_desc, num}; +#[path = "camera_raw_map.rs"] +mod camera_raw; + /// Command id of a Photoshop smart filter PhotoCraft does not implement. Its params hold the /// filter's name, Photoshop filter id and descriptor (`psd`, hex); it renders as a pass-through. pub const UNSUPPORTED_FILTER: &str = "psd.unsupportedFilter"; @@ -302,7 +305,12 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let class = fltr.map(|f| String::from_utf8_lossy(f.class_id.as_bytes()).into_owned()); let filter_id = int(item, "filterID").unwrap_or(0); let class = class.or_else(|| KNOWN.iter().find(|k| k.filter_id == filter_id).map(|k| k.class.to_string())); - let (command, params) = match class.as_deref().and_then(|c| known_params(c, fltr)) { + let known = if class.as_deref() == Some(camera_raw::CLASS) { + camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)) + } else { + class.as_deref().and_then(|c| known_params(c, fltr)) + }; + let (command, params) = match known { Some((c, p)) => (c.to_string(), p), None => { let name = text_of(item, "Nm ").unwrap_or_default(); @@ -432,6 +440,12 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { /// The `filterFXList` item for one smart filter, or why it can't be written. pub fn item_for_filter(f: &SmartFilter) -> Result { + if f.command == camera_raw::COMMAND { + let mut d = camera_raw::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its Photoshop data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its Photoshop data is unreadable ({e})"))?; diff --git a/docs/camera-raw-histogram.md b/docs/camera-raw-histogram.md index 4a787a8cc..e5eb2d53b 100644 --- a/docs/camera-raw-histogram.md +++ b/docs/camera-raw-histogram.md @@ -25,6 +25,42 @@ keyboard nudges and deletion, at most 16 points. Camera Raw commits one `filter. OK; scope interaction never edits the document. Double-clicking a Camera Raw smart filter reopens this dialog with its stored settings and the pixels below it; OK updates that filter in place. +## PSD Smart Filters + +Camera Raw is read and written as a live PSD Smart Filter (`Adobe Camera Raw Filter`, filter id +2783). The verified subset covers filter-relative Temperature/Tint with custom white balance, +Light, Texture/Clarity/Dehaze/Vibrance/Saturation, the parametric curve, four point curves, +all eight HSL bands, all four Color Grading wheels and Blending/Balance, sharpening, +noise amounts/detail, grain and the numeric post-crop vignette controls. Point curves contain +2–16 points with integer Input/Output levels; resetting one writes the linear endpoints. +PSD stores slider levels and curve coordinates as integers while PhotoCraft's sliders are +continuous: PSD export rounds them to the nearest level (hues of 360° are written as 0°), +so a re-opened PSD shows whole numbers. Exposure and sharpening radius stay fractional. +The mapping comes from two revisions of a user-supplied Photoshop PSD written by Camera Raw +18.4 (process 6). Process 5's +corresponding version pair is also recognized; other versions remain opaque. + +Imported filters keep their original descriptor in `params.__cameraRawPsd`. Unchanged settings, +including the exact stored exposure double, are preserved; editing patches only changed mapped +fields. The template survives `.pcraft`, undo and reopening the Camera Raw dialog. Descriptors +with unrecognized processing fields remain `psd.unsupportedFilter`: their data survives a PSD +save, but their settings cannot be edited or re-rendered by PhotoCraft. Non-default PhotoCraft +settings without a verified PSD mapping cause the existing export warning and omission of that +filter; they are never silently written as default Camera Raw settings. + +Active manual lens distortion/vignetting, luminance-noise contrast, color-noise smoothness, +post-crop vignette style (`PCVS`, still unverified) and highlights, RAW Kelvin white balance, +masks and HDR still need mapping and/or rendering support. Observed neutral-only controls are +retained in the template; non-neutral values keep the filter opaque. In the updated fixture, +Distortion −28, Lens Vignetting 28 and `PCVS=1` keep the entire filter opaque, while all settings +survive PSD and `.pcraft` saves. Its supported controls are also checked as a descriptor-only +projection with those three fields removed; that projection does not replace the source filter. + +Matching settings does not +claim matching pixels: PhotoCraft uses its own develop pipeline. PSD round trips and descriptor +patching are checked locally; reopening PhotoCraft-generated exports in Photoshop is a separate +acceptance check. + ## Layering and bounds Analysis lives in L2 `photocraft-algo` (`histogram`, `vectorscope`), colour transforms in L0 @@ -51,7 +87,8 @@ scopes, probes, hover and tessellation). GPU upload and presentation are not inc - HDR histograms, output and display: no suitable HDR hardware was available for validation. Float input and counters outside 0–1 do not imply HDR support. -- Persisting Camera Raw as a PSD Smart Filter (see the file-compatibility scorecard). +- PSD Camera Raw settings outside the verified develop subset described above, and + pixel-identical Adobe Camera Raw rendering (see the file-compatibility scorecard). - Camera Raw's own mask authoring; scopes use the document selection. References: [Adobe, Make color and tonal adjustments](https://helpx.adobe.com/camera-raw/desktop/using/make-color-tonal-adjustments-camera.html), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3b4abd66e..b27b3c4ad 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -315,6 +315,12 @@ opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / least one level apart. Commit dispatches one `filter.cameraRaw` engine command; a failed commit keeps the dialog open for correction. Nothing else writes document history. +Imported PSD Camera Raw filters whose processing settings are all mapped or neutral use this +same editor. `params.__cameraRawPsd` is reserved import/export metadata: preserve it when editing +stored parameters. Other processing fields or versions remain opaque Photoshop filters. See +[PSD Smart Filters](camera-raw-histogram.md#psd-smart-filters) for the supported settings and export +limits. + The response and `ui.inspect.cameraRaw` contain: - `histogram`: `source` (`before` | `after`), `size`, `approximate`, 256-bin `red` / `green` / diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f0c57196..86c2d24e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -82,6 +82,16 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either | Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | | Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. | +2026-10-07: Camera Raw PSD mapping covers relative custom white balance, Light/Presence, +parametric and four point curves, HSL, Color Grading, sharpening/noise detail, grain and numeric +post-crop vignette controls. Two revisions of one supplied Photoshop ACR 18.4 PSD preserve their +filter descriptors without edits. The updated revision stays opaque because of active manual +Optics and unverified vignette style; a descriptor projection verifies its supported controls. +Synthetic 8/16/32-bit PSD → edit → `.pcraft` → PSD tests preserve the filter stack, mask and +undo/redo. Unmapped Camera Raw fields/versions remain opaque; Photoshop +acceptance of generated exports and pixel parity are still unverified. Corpus floors above are +unchanged. + ### Where we're going (priority order) 1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them. diff --git a/docs/scorecard.md b/docs/scorecard.md index 66b3efc3c..1df733f04 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -90,7 +90,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | 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 mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) are left out of filterFX with a warning | +| 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-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 | @@ -100,7 +100,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | | FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | -| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it | +| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 6e80ce33c..8a011f715 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -17,7 +17,7 @@ id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) are left out of filterFX with a warning" +note = "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" [[item]] id = "FILE-215-3" @@ -87,7 +87,7 @@ id = "FILE-215-12" target = "Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter" status = "partial" issue = 215 -note = "Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it" +note = "Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open" [[item]] id = "FILE-215-13"