Integration: Lens Flare dialog centre and Clouds seed 0 on Save Actions (#10620)

The Lens Flare params doc said "centerX":number=0.5, which the filter
dialog reads as an integer field defaulting to 0: Filter > Render > Lens
Flare put the flare in the top-left corner and an imported 0.4 centre
rounded to 0. The doc is back to 0..1=0.5; the engine still plays
off-canvas centres unclamped (a recorded 1.4 centre now has a playback test).

A Clouds or Difference Clouds step recorded through PhotoCraft's dialog
carries {"seed":0}, and Save Actions refused it. The event has no seed
key; seed 0 is what the imported empty step plays as, so it is dropped on
export. Any other seed still plays differently and is still refused.

Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-02 02:55:32 +01:00
1 parent 33ee38b431
commit 563666d69a
4 files changed
+40 -3

No files matched your search

@@ -99,6 +99,26 @@ fn clouds_events_map_and_refuse_unrepresentable_native_settings() {
for p in [json!({"seed": 7}), json!({"foreground": "#ff0000"}), json!({"background": "#0000ff"}), json!({"layer": 7}), json!({"target": "mask"})] {
assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}");
}
// The Clouds dialog records `seed: 0`, which is what the empty imported step plays as.
for p in [json!({"seed": 0}), json!({"seed": 0.0}), json!({"seed": 0, "target": "pixels"})] {
assert_eq!(export_item(id, &p, "Render").map(|item| item.descriptor), Some(Some(desc("null"))), "{id}: {p}");
}
for p in [json!({"seed": -1}), json!({"seed": 0.5}), json!({"seed": "0"}), json!({"seed": 0, "foreground": "#ff0000"})] {
assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}");
}
}
}
/// A recorded dialog step saves through the real Save Actions command.
#[test]
fn clouds_recorded_through_the_dialog_save() {
for id in ["filter.render.clouds", "filter.render.differenceClouds"] {
let mut s = Session::new();
s.actions.list.push(Action::new("Render", vec![(id.into(), json!({"seed": 0}))]));
let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap();
assert_eq!(saved["skipped"], json!([]), "{id}");
let back = import(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(back.actions[0].steps, vec![(id.into(), json!({}))], "{id}");
}
}
@@ -238,6 +258,8 @@ fn render_actions_match_direct_filters_and_undo() {
(*b"DfrC", desc("null"), "filter.render.differenceClouds", json!({})),
(*b"Fbrs", fibers(23, 6, -7), "filter.render.fibers", json!({"variance": 23, "strength": 6, "seed": -7})),
(*b"LnsF", flare(240, [5.0, -5.0], "Nkn1"), "filter.render.lensFlare", json!({"brightness": 240, "centerX": 5.0, "centerY": -5.0, "lens": "prime105"})),
// Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre.
(*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})),
];
for mode in ["rgb", "gray"] {
for depth in [8, 16, 32] {
@@ -42,9 +42,12 @@ pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped {
if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } }
}
/// Explicit seeds, colors and layer references have no fields in these ATN events.
/// Explicit colors and layer references have no fields in these ATN events. A seed has none
/// either, but `seed: 0` (what PhotoCraft's dialog records) is what the imported empty step
/// plays as, so it is dropped; any other seed would play differently and is refused.
pub(super) fn clouds_to(_: &str, p: &Value) -> Option<Descriptor> {
params(p, &[]).then(|| desc("null"))
let seed_replays = p.get("seed").is_none_or(|s| s.as_f64() == Some(0.0));
(params(p, &["seed"]) && seed_replays).then(|| desc("null"))
}
/// Capture a missing recorded seed once; later playback and export reuse the stored bits.
+1 -1
View File
@@ -725,7 +725,7 @@ pub fn specs() -> Vec<CommandSpec> {
"filter.render.lensFlare",
"Lens Flare…",
["Filter", "Render"],
r##"{"brightness":10..300=100,"centerX":number=0.5,"centerY":number=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized center coordinates; positions outside the image are supported)"##
r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized centre: 0..1 is the canvas; agents and actions may pass values outside it, which place the flare off the canvas unclamped)"##
),
cmd!(
"filter.render.lightingEffects",
@@ -22,6 +22,18 @@ fn a_unit_after_a_range_bound_does_not_reset_the_range_to_its_fallback() {
assert!(matches!(size.map(|p| p.kind), Some(Kind::Range { max, .. }) if max == 5000.0));
}
#[test]
fn lens_flare_dialog_centre_is_a_slider_from_0_to_1_defaulting_to_the_middle() {
// `"centerX":number=0.5` read as an integer field defaulting to 0: the menu's Lens Flare
// landed in the top-left corner and an imported 0.4 centre rounded to 0.
let spec = photocraft_engine::commands::find("filter.render.lensFlare").expect("filter.render.lensFlare is registered");
let params = parse_spec(spec.params);
for key in ["centerX", "centerY"] {
let kind = params.iter().find(|p| p.key == key).map(|p| p.kind.clone());
assert_eq!(kind, Some(Kind::Range { min: 0.0, max: 1.0, default: 0.5 }), "{key}");
}
}
#[test]
fn color_settings_working_spaces_list_installed_profiles_of_their_space() {
// #6323: a press condition installed in the system can be the CMYK working space, as in