* Camera Raw: read and write settings as a live PSD Smart Filter (part of #215) - Map Camera Raw 18.4 (process 5/6) filter settings to and from PSD: custom white balance, Light, Presence, parametric and four point curves, HSL, Color Grading, sharpening, noise, grain and numeric post-crop vignette controls. - Keep the original Photoshop descriptor; edits patch only changed fields. Unknown settings or versions stay an opaque filter, preserved byte for byte. - PSD stores integer slider levels: export rounds continuous PhotoCraft values (hue 360 -> 0) instead of dropping the filter. - Tests: field mapping, patching, opaque fallback, malformed input, rounding, and 8/16/32-bit PSD -> edit -> .pcraft -> PSD with mask, filter order and undo/redo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Camera Raw PSD: no product names in Rust comments, strings or test names Only the descriptor class key itself keeps its literal name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2b8be01d6f
commit
4b3da49486
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<J> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<J> {
|
||||
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<Vec<[f32; 2]>> = 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<J> {
|
||||
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<J> {
|
||||
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<Descriptor, String> {
|
||||
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::<Vec<_>>()) };
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Option<Descriptor>, String> {
|
|||
|
||||
/// The `filterFXList` item for one smart filter, or why it can't be written.
|
||||
pub fn item_for_filter(f: &SmartFilter) -> Result<Descriptor, String> {
|
||||
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})"))?;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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` /
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue