From 9dc2bafe400357185df052f18a94edf67ac7344e Mon Sep 17 00:00:00 2001 From: Fini Date: Thu, 23 Jul 2026 01:16:23 +0800 Subject: [PATCH] fix(agent): preserve compact avatar media during cleanup --- .../op-orchestrator/src/radial_stub_tests.rs | 31 ++++++++++++++ crates/op-orchestrator/src/stub_repair.rs | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/op-orchestrator/src/radial_stub_tests.rs b/crates/op-orchestrator/src/radial_stub_tests.rs index 24c489468..95b470c9a 100644 --- a/crates/op-orchestrator/src/radial_stub_tests.rs +++ b/crates/op-orchestrator/src/radial_stub_tests.rs @@ -271,6 +271,37 @@ fn cleanup_deletes_only_empty_decorated_small_frame_stubs() { assert!(find_by_name(&root, "Spacer").is_some()); } +#[test] +fn cleanup_preserves_image_filled_avatar_leaf_in_header() { + let mut sink = insert_root(json!({ + "type":"frame","id":"root","name":"Root","width":390,"height":160,"layout":"vertical","children":[ + {"type":"frame","id":"top-bar","name":"Top Bar","width":"fill_container","height":52, + "layout":"horizontal","justifyContent":"space_between","children":[ + {"type":"text","id":"title","name":"Title","content":"Hearth"}, + {"type":"frame","id":"avatar","name":"User Portrait","width":40,"height":40, + "cornerRadius":20,"fill":[{"type":"image","url":"data:image/png;base64,AAAA","mode":"crop"}],"children":[]}, + {"type":"frame","id":"avatar-fallback","name":"Profile Avatar","role":"avatar","width":36,"height":36, + "cornerRadius":18,"fill":[{"type":"solid","color":"#334155"}],"children":[]} + ]} + ] + })); + + run_cleanup(&mut sink); + + let root = active_root_json(&sink); + let top_bar = find_by_name(&root, "Top Bar").expect("top bar survives"); + assert_eq!( + top_bar + .get("children") + .and_then(Value::as_array) + .map(Vec::len), + Some(3), + "cleanup must not delete compact image-filled or semantic avatar leaves" + ); + assert!(find_by_name(&root, "User Portrait").is_some()); + assert!(find_by_name(&root, "Profile Avatar").is_some()); +} + #[test] fn geometry_diagnostics_reports_empty_decorated_frame_stub() { let sink = insert_root(json!({ diff --git a/crates/op-orchestrator/src/stub_repair.rs b/crates/op-orchestrator/src/stub_repair.rs index 2737f6be8..aa0bb3523 100644 --- a/crates/op-orchestrator/src/stub_repair.rs +++ b/crates/op-orchestrator/src/stub_repair.rs @@ -74,12 +74,53 @@ fn is_empty_decorated_stub(v: &Value, resolved_size: Option<(f64, f64)>) -> bool && v.get("x").is_none() && v.get("y").is_none() && children(v).is_empty() + // A frame whose paint is an image is already meaningful content. + // Compact avatar/photo frames are commonly authored this way, with + // no child image node, so treating them as empty stubs destroys the + // asset before the later avatar-repair pass can inspect it. + && !is_meaningful_media_leaf(v) && has_visible_paint(v) && (padding_positive(v) || numeric(v, "cornerRadius").is_some_and(|r| r > 0.0)) && width.is_some_and(|w| w > 0.0 && w < 80.0) && height.is_some_and(|h| h > 0.0 && h < 60.0) } +fn is_meaningful_media_leaf(v: &Value) -> bool { + has_image_fill(v) || has_avatar_semantics(v) +} + +fn has_image_fill(v: &Value) -> bool { + match v.get("fill") { + Some(Value::Array(fills)) => fills.iter().any(is_image_paint), + Some(fill) => is_image_paint(fill), + None => false, + } +} + +fn has_avatar_semantics(v: &Value) -> bool { + let role = v.get("role").and_then(Value::as_str).unwrap_or_default(); + if matches!( + role.to_ascii_lowercase().as_str(), + "avatar" | "user-avatar" | "profile-avatar" | "profile-photo" + ) { + return true; + } + + let name = v + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + name.contains("avatar") || name.contains("profile photo") || name.contains("user photo") +} + +fn is_image_paint(paint: &Value) -> bool { + paint + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind.eq_ignore_ascii_case("image")) +} + fn resolved_sizes(state: &op_editor_core::EditorState) -> HashMap { let scene = op_pen_loader::editor_state_to_layout_scene(state); let mut out = HashMap::new();