diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 2b2b3d4b7..a0c2f94b5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -632,14 +632,30 @@ pub trait RemoteFrames: Send + Sync { /// Whether a comp (or a comp it nests) has a layer with enabled effects. pub fn comp_has_effects(project: &Project, comp: ItemId) -> bool { - fn walk(p: &Project, comp: ItemId, depth: usize) -> bool { - let Some(c) = p.comp(comp) else { return false }; - c.layers.iter().any(|l| { - l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) - || depth < 16 && matches!(l.source, effectcraft_engine::project::LayerSource::Comp { item } if walk(p, item, depth + 1)) - }) + // Breadth first with a visited set: each comp is looked at once, at its shallowest level, so a + // comp first met deep in one chain is still found by a shorter route (a result memoised by + // comp alone would depend on the depth it was first reached at). + let mut seen = std::collections::HashSet::from([comp]); + let mut level = vec![comp]; + // Comps nested up to 16 levels below the first are searched. + for _ in 0..=16 { + let mut next = vec![]; + for id in level { + let Some(c) = project.comp(id) else { continue }; + for l in &c.layers { + if l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) { + return true; + } + if let effectcraft_engine::project::LayerSource::Comp { item } = l.source + && seen.insert(item) + { + next.push(item); + } + } + } + level = next; } - walk(project, comp, 0) + false } pub struct Frames { diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index baf6342e7..45398de21 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3373,3 +3373,51 @@ fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { assert!(!DRAINING_LANE.get()); }); } + +#[test] +fn effect_detection_answers_each_comp_once() { + // Sixteen levels, each comp holding eight layers of the one below and no effects: 8^16 walks + // without a memo. Answered at once. + use effectcraft_engine::project::{Comp, ItemKind, LayerSource, build}; + use effectcraft_engine::time::{FrameRate, Tick}; + let mut p = Project::default(); + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mut below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(base.clone().into())); + for _ in 0..16 { + let layers: Vec<_> = (0..8).map(|_| build::layer(&mut p, &base, "a", LayerSource::Comp { item: below }, (0, 0), None)).collect(); + let mut c = base.clone(); + c.layers = layers.into(); + below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(c.into())); + } + let start = std::time::Instant::now(); + assert!(!comp_has_effects(&p, below)); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); +} + +#[test] +fn an_effect_found_by_a_short_route_is_not_hidden_by_a_deep_one() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = |s: &mut Session| ItemId(id(s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); + let nest = |s: &mut Session, outer: ItemId, inner: ItemId| { + s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0, "time": 0.0})).unwrap(); + }; + // `fx` holds a layer with an effect; seventeen comps stack above it, one level too many to be found. + let fx = comp(&mut s); + let l = id(s.execute("layer.newSolid", json!({"comp": fx.0, "color": "#ff0000"})).unwrap(), "layer"); + s.execute("effect.apply", json!({"comp": fx.0, "layers": [l], "effect": "ec.vr.converter"})).unwrap(); + let mut chain = fx; + for _ in 0..17 { + let up = comp(&mut s); + nest(&mut s, up, chain); + chain = up; + } + assert!(!comp_has_effects(&s.project, chain), "too deep to be found"); + // A root that nests the chain first and `fx` directly after it. + let root = comp(&mut s); + nest(&mut s, root, chain); + nest(&mut s, root, fx); + assert!(comp_has_effects(&s.project, root)); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 753b2884e..038ed5e15 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -329,7 +329,7 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { return cached; } - let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| { + let tex = img.bytes(services).and_then(|b| decode_ui_image(&b)).map(|d| { let rgba = d.to_rgba8(); let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR) @@ -338,6 +338,21 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - tex } +/// The largest side of a ScriptUI image (icons and banners are far smaller). +const MAX_UI_IMAGE_SIDE: u32 = 4096; + +/// A ScriptUI image decoded within [`MAX_UI_IMAGE_SIDE`] per side and a matching allocation +/// limit, checked from the header before the pixels (and their RGBA copy) are allocated. +fn decode_ui_image(bytes: &[u8]) -> Option { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_UI_IMAGE_SIDE); + limits.max_image_height = Some(MAX_UI_IMAGE_SIDE); + limits.max_alloc = Some(u64::from(MAX_UI_IMAGE_SIDE) * u64::from(MAX_UI_IMAGE_SIDE) * 16); + r.limits(limits); + r.decode().ok() +} + /// An image control's or icon button's image, fitted (aspect kept) and centred in `r`. fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool { let Some(tex) = image_texture(ui.ctx(), img, services) else { return false };