From 39e6907b5fdee2693ca90453525d2eff8d1acd95 Mon Sep 17 00:00:00 2001 From: Dexter <37185622+dexterlabs1@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:00:31 -0500 Subject: [PATCH] G2: improve editing reliability and Windows rendering fidelity --- apps/effectcraft-web/src/store.rs | 4 +- apps/effectcraft/src/audio_out.rs | 2 +- crates/automation/src/lib.rs | 4 +- crates/av1enc/tests/ffmpeg_oracle.rs | 2 +- crates/color/src/icc.rs | 2 +- crates/effects/src/audio_fx.rs | 10 +- crates/effects/src/catalog.rs | 3 +- crates/engine/src/commands/keys_more.rs | 2 +- crates/engine/src/media_cache.rs | 2 +- crates/engine/src/rq_tests.rs | 9 +- crates/engine/src/templates.rs | 15 +- crates/engine/src/tests_disk_cache.rs | 1 + crates/engine/src/tests_m145.rs | 4 +- crates/engine/src/tests_menu_cmds.rs | 1 + crates/engine/src/tests_models.rs | 1 + crates/engine/src/tests_roto.rs | 16 + crates/engine/src/tests_settings.rs | 4 +- crates/engine/src/tests_stubs.rs | 27 +- crates/export/src/encode.rs | 4 +- crates/export/src/lib.rs | 6 +- crates/export/tests/opus_roundtrip.rs | 2 +- crates/export/tests/render_settings.rs | 17 +- crates/export/tests/roundtrip.rs | 2 +- crates/gpu/src/adv3d.rs | 14 +- crates/gpu/src/context.rs | 6 + crates/gpu/src/ops.rs | 5 +- crates/gpu/src/particles.rs | 6 +- crates/gpu/src/shaders/fx_key.wgsl | 11 +- crates/gpu/src/shaders/fx_noise.wgsl | 7 +- crates/gpu/src/shaders/fx_stylize.wgsl | 7 +- crates/gpu/src/shaders/kernels.wgsl | 32 +- crates/gpu/src/tests.rs | 26 +- crates/gpu/src/tests_fx_key.rs | 21 + crates/gpu/src/tests_fx_noise.rs | 23 ++ crates/host/src/lib.rs | 6 +- crates/keyframe/examples/ae_ease_samples.rs | 34 ++ crates/keyframe/src/lib.rs | 133 ++++++- crates/lottie/src/anim.rs | 8 +- crates/lottie/src/tests.rs | 4 + crates/media/src/convert.rs | 4 +- crates/media/src/pool.rs | 2 +- crates/model/src/gltf.rs | 18 +- crates/model/src/lib.rs | 6 +- crates/model/src/obj.rs | 2 +- crates/opusenc/src/rate.rs | 10 +- crates/opusenc/tests/ffmpeg.rs | 2 +- crates/path/src/offset.rs | 2 +- crates/pdf/src/cff.rs | 12 +- crates/pdf/src/color.rs | 2 +- crates/pdf/src/font.rs | 14 +- crates/pdf/src/ps.rs | 6 +- crates/pdf/src/type1.rs | 2 +- crates/plugin/src/wasm.rs | 4 +- crates/project/src/render_queue.rs | 6 +- crates/psd/src/engine_data.rs | 2 +- crates/psd/src/lib.rs | 6 +- crates/raster/src/channels3d.rs | 3 +- crates/render/src/audio.rs | 4 +- crates/render/src/tests_collapse.rs | 10 +- crates/render/src/three_d/adv/scene.rs | 4 +- crates/segment/src/lib.rs | 4 +- crates/segment/src/mediapipe.rs | 2 +- crates/segment/src/pt.rs | 6 +- crates/segment/src/tflite.rs | 10 +- crates/svg/src/lib.rs | 2 +- crates/text/src/sfnt.rs | 6 +- crates/track/src/camtrack/linalg.rs | 5 +- crates/ui-egui/src/frames.rs | 30 +- crates/ui-egui/src/lib.rs | 33 +- crates/ui-egui/src/menus.rs | 90 ++++- .../ui-egui/src/panels/camera_tracker_ui.rs | 1 + crates/ui-egui/src/panels/dialogs.rs | 21 +- crates/ui-egui/src/panels/effect_controls.rs | 4 + crates/ui-egui/src/panels/essential.rs | 24 +- crates/ui-egui/src/panels/home.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 39 +- crates/ui-egui/src/panels/project.rs | 58 ++- crates/ui-egui/src/panels/render_queue.rs | 21 + crates/ui-egui/src/panels/settings.rs | 42 +- crates/ui-egui/src/panels/templates.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 168 +++++--- crates/ui-egui/src/panels/viewer.rs | 72 +++- crates/ui-egui/src/panels/viewer_text.rs | 2 +- crates/ui-egui/src/panels/viewer_tools.rs | 4 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/theme.rs | 10 +- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/tests/ui_camera_tracker.rs | 17 + crates/ui-egui/tests/ui_gpu.rs | 2 + crates/ui-egui/tests/ui_project_delete.rs | 2 +- crates/ui-egui/tests/ui_text_edit.rs | 18 + crates/ui-egui/tests/ui_visual_review.rs | 50 +++ crates/ui-egui/tests/ui_workflow_selection.rs | 374 ++++++++++++++++++ crates/vp9enc/src/lib.rs | 2 +- docs/fidelity/README.md | 57 +++ docs/fidelity/ae-2026-acceptance.md | 48 +++ docs/fidelity/keyframe-ease.json | 57 +++ docs/fidelity/workflow-checks.md | 94 +++++ docs/gaps.md | 2 +- examples/ae-ease-oracle.jsx | 44 +++ examples/check-ae-ease.mjs | 44 +++ 101 files changed, 1727 insertions(+), 351 deletions(-) create mode 100644 crates/keyframe/examples/ae_ease_samples.rs create mode 100644 crates/ui-egui/tests/ui_visual_review.rs create mode 100644 crates/ui-egui/tests/ui_workflow_selection.rs create mode 100644 docs/fidelity/README.md create mode 100644 docs/fidelity/ae-2026-acceptance.md create mode 100644 docs/fidelity/keyframe-ease.json create mode 100644 docs/fidelity/workflow-checks.md create mode 100644 examples/ae-ease-oracle.jsx create mode 100644 examples/check-ae-ease.mjs diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 78dc4915..ae02649b 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -477,8 +477,8 @@ mod tests { a.execute("comp.new", json!({"name": "C"})).unwrap(); // Untitled: the auto-save lands in the store's file table. let p = a.autosave_now().unwrap(); - assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj"); - assert!(s.file(&p).is_some()); + assert_eq!(Path::new(&p), Path::new("/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj")); + assert!(s.file(&path_str(Path::new(&p))).is_some()); // The tab closes without a clean exit: the next visit offers the auto-save. let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() }; b.load_settings(); diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index 06ab9785..a7929b6d 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -76,7 +76,7 @@ fn run>( let frames = out.len() / ch.max(1); stereo.resize(frames * 2, 0.0); feed.pull(&mut stereo); - for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) { + for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.as_chunks::<2>().0.iter()) { for (c, o) in f.iter_mut().enumerate() { // Mono devices, or left and right mapped to the same channel, get the mix. let v = if ch == 1 || (c == left && c == right) { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index fc3767b1..b4040bab 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -60,8 +60,8 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec, max_side: u32) -> Result = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + let r = if raw.as_chunks::<4>().0.iter().all(|p| p[3] == 255) { + let rgb: Vec = raw.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8) } else { enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8) diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/ffmpeg_oracle.rs index c1a087d2..66f380ac 100644 --- a/crates/av1enc/tests/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/ffmpeg_oracle.rs @@ -75,7 +75,7 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec { let raw = std::fs::read(&output).expect("decoded yuv"); let _ = std::fs::remove_dir_all(&dir); let samples: Vec = - if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }; + if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }; let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let fsize = w * h + 2 * cw * ch; assert_eq!(samples.len(), fsize * pics.len(), "{name}: decoded frame count"); diff --git a/crates/color/src/icc.rs b/crates/color/src/icc.rs index e85927a8..184c94c2 100644 --- a/crates/color/src/icc.rs +++ b/crates/color/src/icc.rs @@ -222,7 +222,7 @@ fn description(t: Option<&[u8]>) -> Option { // The first record: UTF-16BE. let len = be32(t, 20)? as usize; let off = be32(t, 24)? as usize; - let u: Vec = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = t.get(off..off + len)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string()) } _ => None, diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index e6f7db47..a5e1fc4e 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -247,7 +247,7 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64 match id { "ec.audio.backwards" => { if params.b("swapChannels") { - buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1)); + buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1)); } } "ec.audio.basstreble" => { @@ -471,7 +471,7 @@ fn compressor(params: &Params, buf: &mut [f32], sr: f64) { let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32; // Smoothed gain change in dB: attack while the reduction grows, release as it shrinks. let (mut g, mut gs) = (0.0f64, 0.0f64); - for f in buf.chunks_exact_mut(2) { + for f in buf.as_chunks_mut::<2>().0.iter_mut() { let peak = f[0].abs().max(f[1].abs()) as f64; let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee); let k = if target < g { att } else { rel }; @@ -497,7 +497,7 @@ fn gate(params: &Params, buf: &mut [f32], sr: f64) { // Openness 0..1 (closed..open) is the gain: a closed gate mutes. let mut g = 0.0f64; let mut held = 0usize; - for f in buf.chunks_exact_mut(2) { + for f in buf.as_chunks_mut::<2>().0.iter_mut() { let peak = f[0].abs().max(f[1].abs()) as f64; let open = if peak >= th { held = hold; @@ -560,7 +560,7 @@ fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) { // Sample-and-hold on absolute sample positions, so blocks agree with one long run. let first = (start * sr).round() as i64; let mut held = [0.0f32; 2]; - for (i, f) in buf.chunks_exact_mut(2).enumerate() { + for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() { let take = i == 0 || (first + i as i64).rem_euclid(down) == 0; for ch in 0..2 { let x = f[ch]; @@ -696,7 +696,7 @@ fn stereo_mixer(params: &Params, buf: &mut [f32]) { let (l_to_l, l_to_r) = pan(params.f("leftPan")); let (r_to_l, r_to_r) = pan(params.f("rightPan")); let inv = if params.b("invertPhase") { -1.0 } else { 1.0 }; - for f in buf.chunks_exact_mut(2) { + for f in buf.as_chunks_mut::<2>().0.iter_mut() { let (l, r) = (f[0] * ll, f[1] * rl); f[0] = (l * l_to_l + r * r_to_l) * inv; f[1] = (l * l_to_r + r * r_to_r) * inv; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 2495897c..009220c8 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -173,7 +173,8 @@ mod tests { std::fs::write(path, &want).unwrap(); return; } - let have = std::fs::read_to_string(path).unwrap_or_default(); + // A Windows checkout may use CRLF; catalog content must still match exactly. + let have = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n"); assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } diff --git a/crates/engine/src/commands/keys_more.rs b/crates/engine/src/commands/keys_more.rs index a64584f4..c7785d08 100644 --- a/crates/engine/src/commands/keys_more.rs +++ b/crates/engine/src/commands/keys_more.rs @@ -58,7 +58,7 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result { pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) { let n = (buf.len() / 2).max(1) as f64; let (mut l, mut r) = (0.0f64, 0.0f64); - for c in buf.chunks_exact(2) { + for c in buf.as_chunks::<2>().0 { l += c[0].abs() as f64; r += c[1].abs() as f64; } diff --git a/crates/engine/src/media_cache.rs b/crates/engine/src/media_cache.rs index 1a9b932a..0a564d87 100644 --- a/crates/engine/src/media_cache.rs +++ b/crates/engine/src/media_cache.rs @@ -24,7 +24,7 @@ pub fn load_peaks(file: &Path) -> Option> { return None; } let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]); - Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect()) + Some(b.as_chunks::<16>().0.iter().map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect()) } /// Store a summary (atomically: written beside, then renamed). diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 8ffa8dcb..60a73acf 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -69,7 +69,7 @@ fn add_configure_render() { assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err()); let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap(); assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes"); - assert_eq!(r["outputPath"], "/tmp/rq/out.mov"); + assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap()); // A second item that fails, a third that is unqueued. let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap(); let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap(); @@ -77,11 +77,14 @@ fn add_configure_render() { assert_eq!(s.project.render_queue[2].status.label(), "Unqueued"); let r = s.execute("renderQueue.render", json!({})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 2); - assert_eq!(*ex.log.lock().unwrap(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]); + assert_eq!( + ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::>(), + [std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()] + ); let q = &s.project.render_queue; assert_eq!(q[0].status.label(), "Done"); assert!(q[0].started.is_some() && q[0].render_time.is_some()); - assert_eq!(q[0].last_output.as_deref(), Some("/tmp/rq/out.mov")); + assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap()); assert_eq!(q[1].status, RenderStatus::Failed("boom".into())); assert_eq!(q[2].status.label(), "Unqueued"); assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. }))); diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs index 00d17a98..a8009f47 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/templates.rs @@ -716,15 +716,14 @@ fn footage_dir(s: &Session, name: &str, footage_dir: Option<&str>, project_path: } let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect(); let safe = if safe.trim().is_empty() { "Template".to_string() } else { safe.trim().to_string() }; - let base = match s.config.as_ref().and_then(|c| c.dir()) { - Some(d) => d.join(FOOTAGE_DIR), - None => PathBuf::from(format!("/{FOOTAGE_DIR}")), - }; + let base = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(FOOTAGE_DIR)); // A fresh folder per created project. let mut k = 1; loop { - let d = base.join(if k == 1 { safe.clone() } else { format!("{safe} {k}") }); - let ds = d.to_string_lossy().into_owned(); + let folder = if k == 1 { safe.clone() } else { format!("{safe} {k}") }; + // Virtual storage keys use '/' independently of the host OS. Native settings + // folders retain their platform path semantics. + let ds = base.as_ref().map(|d| d.join(&folder).to_string_lossy().into_owned()).unwrap_or_else(|| format!("/{FOOTAGE_DIR}/{folder}")); if !s.services.exists(&ds) || k > 999 { return ds; } @@ -955,7 +954,7 @@ mod tests { assert!(t.width > 0 && t.duration > 0.0, "{t:?}"); let th = decode_thumb(&thumbnail(&s, &t.id).unwrap()).unwrap(); // Not a blank frame: some variety in the pixels. - let distinct: std::collections::BTreeSet<[u8; 3]> = th.chunks_exact(3).map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect(); + let distinct: std::collections::BTreeSet<[u8; 3]> = th.as_chunks::<3>().0.iter().map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect(); assert!(distinct.len() > 3, "{}: thumbnail looks blank", t.id); } let sq = l.iter().find(|t| t.id == "social-square").unwrap(); @@ -1121,7 +1120,7 @@ mod tests { let dir = root.join("work").join("Promo Footage"); let fp = footage_paths(&s); let get = |n: &str| fp.iter().find(|f| f.0 == n).unwrap().clone(); - assert_eq!(get("Still").1, dir.join("still.png").to_string_lossy()); + assert_eq!(std::path::Path::new(&get("Still").1), dir.join("still.png")); assert_eq!(std::fs::read(dir.join("still.png")).unwrap(), vec![1u8; 1000]); let shot = get("Shot"); assert_eq!(shot.2.len(), 3); diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs index bdefe6b6..cc63cb43 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests_disk_cache.rs @@ -30,6 +30,7 @@ fn project(s: &mut Session) -> effectcraft_project::ItemId { #[test] fn disk_cache_settings_persistence_and_invalidation() { + crate::tests_roto::hold_roto_cache_test_lock(); let dir = folder("persist"); let mut s = session(&dir); let st = s.execute_checked("cache.diskStats", json!({})).unwrap(); diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index d538da63..c168448e 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -244,7 +244,7 @@ fn import_export_helpers() { // Append Bit Depth to File Name. assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png"); p.export.append_bits_to_name = true; - assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png"); + assert_eq!(PathBuf::from(p.output_name("/out/Comp 1.png", 16)), PathBuf::from("/out/Comp 1_16bpc.png")); assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr"); // Segments: sequences by file count, movies by size at the data rate. assert_eq!(p.segment_frames(100, true, 0.0), None); @@ -299,7 +299,7 @@ fn render_queue_uses_default_folder_bit_depth_names_and_segments() { assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB"); item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB let segs = s.segments(&item, "/o/Main.mp4"); - assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]); + assert_eq!(segs.iter().map(|(_, p)| PathBuf::from(p)).collect::>(), [PathBuf::from("/o/Main_001.mp4"), PathBuf::from("/o/Main_002.mp4")]); } #[test] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 59c1403b..22b6cab4 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -255,6 +255,7 @@ fn lift_and_extract_work_area() { #[test] fn label_group_purge_and_edit_original() { + crate::tests_roto::hold_roto_cache_test_lock(); let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); diff --git a/crates/engine/src/tests_models.rs b/crates/engine/src/tests_models.rs index 248aeda3..66111eb3 100644 --- a/crates/engine/src/tests_models.rs +++ b/crates/engine/src/tests_models.rs @@ -28,6 +28,7 @@ fn wait_loaded(s: &mut Session, task: Task) { #[test] fn models_are_listed_chosen_and_verified() { + crate::tests_roto::hold_roto_cache_test_lock(); let (mut s, d) = session("list"); std::fs::create_dir_all(&d).unwrap(); let bogus = d.join("bogus.bin"); diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index 41d8e86e..c1cbd2c8 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -10,6 +10,21 @@ use serde_json::{Value, json}; use crate::Session; use crate::tests_track::{H, W, frame_time, setup}; +/// Keep cache/model mutation tests from clearing segmentations under another fixture. +/// Libtest runs each test on its own thread; the guard lives until that thread exits. +pub(crate) fn hold_roto_cache_test_lock() { + use std::cell::RefCell; + use std::sync::{Mutex, MutexGuard, PoisonError}; + static LOCK: Mutex<()> = Mutex::new(()); + thread_local! { + static HELD: RefCell>> = const { RefCell::new(None) }; + } + HELD.with(|h| { + if h.borrow().is_none() { + *h.borrow_mut() = Some(LOCK.lock().unwrap_or_else(PoisonError::into_inner)); + } + }); +} const R: f64 = 40.0; fn noise(x: f64, y: f64, seed: u32) -> f64 { @@ -74,6 +89,7 @@ fn line(a: [f64; 2], b: [f64; 2]) -> Value { /// frame 0. `name`: the footage file name (segmentations are cached process-wide by content key, /// which includes the footage's identity: different clips need different names). fn painted(path: fn(u32) -> [f64; 2], name: &str) -> (Session, LayerId, u64) { + hold_roto_cache_test_lock(); let (mut s, clip, solid) = setup(move |f| draw(path(f))); let cid = s.active_comp_id().unwrap(); s.edit("remove solid", None, |p, _| { diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index e1820578..3561d58b 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -460,8 +460,8 @@ fn open_recent_increment_and_revert() { assert!(s.prefs.recent_projects[0].ends_with("A.ecproj")); s.execute("file.clearRecent", json!({})).unwrap(); assert!(!s.is_enabled("file.openRecent")); - assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj"); - assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj"); + assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/Intro 9.ecproj", |_| false)), std::path::PathBuf::from("/x/Intro 10.ecproj")); + assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj"))), std::path::PathBuf::from("/x/v1 3.ecproj")); let _ = std::fs::remove_dir_all(d); } diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index decd8a72..46d5d17f 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -284,9 +284,20 @@ fn add_output_module_encodes_twice() { s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap(); assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err()); s.execute("renderQueue.render", json!({})).unwrap(); - assert_eq!(*ex.log.lock().unwrap(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]); + assert_eq!( + ex.log + .lock() + .unwrap() + .iter() + .map(|entry| { + let (format, path) = entry.split_once(':').unwrap(); + (format.to_string(), std::path::PathBuf::from(path)) + }) + .collect::>(), + [("H264".to_string(), std::path::absolute("/tmp/om/main.mp4").unwrap()), ("ProRes".to_string(), std::path::absolute("/tmp/om/second.mov").unwrap())] + ); assert_eq!(s.project.render_queue[0].status.label(), "Done"); - assert_eq!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/om/main.mp4")); + assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/om/main.mp4").unwrap()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit"); } @@ -307,7 +318,9 @@ fn pre_render_imports_and_replaces() { assert_eq!(ex.log.lock().unwrap().len(), 1); let l = &s.project.comp(outer).unwrap().layers[0]; let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) }; - assert!(matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/pre/inner.mov")); + assert!( + matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/pre/inner.mov").unwrap()) + ); s.execute("edit.undo", json!({})).unwrap(); assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. })); } @@ -323,13 +336,15 @@ fn post_render_actions_import_and_set_proxy() { assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import); s.execute("renderQueue.render", json!({})).unwrap(); assert_eq!(s.project.items.len(), items_before + 1); - assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/post/a.mov"))); + assert!(s.project.items.values().any( + |i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/post/a.mov").unwrap()) + )); // Set Proxy: the comp gets the render as its proxy. let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap(); s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap(); s.execute("renderQueue.render", json!({})).unwrap(); let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set"); - assert_eq!(px.footage.path, "/tmp/post/b.mov"); + assert_eq!(std::path::Path::new(&px.footage.path), std::path::absolute("/tmp/post/b.mov").unwrap()); // None: nothing happens. let n = s.project.items.len(); let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap(); @@ -413,7 +428,7 @@ fn log_notify_and_overflow_settings() { let (mut s, _) = rq(); let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap(); assert_eq!(a["logLabel"], "Plus Per Frame Info"); - assert_eq!(a["logPath"], "/tmp/n/a_RenderLog.txt"); + assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::absolute("/tmp/n/a_RenderLog.txt").unwrap()); s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap(); assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings); s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap(); diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 777c4dd7..b42ec6a2 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -162,7 +162,7 @@ impl VideoEncoder for ProRes { let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha); rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr); if let Some(a) = fr.alpha.as_mut() { - for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) { + for (d, s) in a.iter_mut().zip(rgba.as_chunks::<4>().0.iter()) { *d = ((s[3] as u32 * 1023 + 127) / 255) as u16; } } @@ -217,7 +217,7 @@ fn deinterleave(buf: &[f32], channels: usize) -> Vec> { /// (mono = the average of left and right). pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec { let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr); - if cx.output.audio_channels == 1 { st.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st } + if cx.output.audio_channels == 1 { st.as_chunks::<2>().0.iter().map(|p| (p[0] + p[1]) * 0.5).collect() } else { st } } /// PCM bytes of samples in `fmt` (little or big endian). diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 77b48450..81e0f5c2 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -322,7 +322,7 @@ fn write_still( } OutputFormat::JpegSequence => { let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h); - let rgb: Vec = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + let rgb: Vec = px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100)) .write_image(&rgb, w, h, ExtendedColorType::Rgb8) .map_err(|e| ExportError::Encode(e.to_string())) @@ -331,8 +331,8 @@ fn write_still( let px = cx.pixels(img, comp, channels, w, h); let (data, ct) = match channels { Channels::Rgba => (px, ExtendedColorType::Rgba8), - Channels::Alpha => (px.chunks_exact(4).map(|p| p[0]).collect(), ExtendedColorType::L8), - Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), + Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8), + Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), }; if fmt == OutputFormat::PngSequence { image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) diff --git a/crates/export/tests/opus_roundtrip.rs b/crates/export/tests/opus_roundtrip.rs index 5ea099f0..3cd1edee 100644 --- a/crates/export/tests/opus_roundtrip.rs +++ b/crates/export/tests/opus_roundtrip.rs @@ -352,7 +352,7 @@ fn ffmpeg_oracle_decodes_ogg_opus() { .expect("run ffmpeg"); let _ = std::fs::remove_file(&path); assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr)); - let dec: Vec = out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); + let dec: Vec = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); assert_eq!(dec.len(), len * c, "ffmpeg must honour pre-skip and the end granule"); for ch in 0..c { let d: Vec = (0..len).map(|i| dec[i * c + ch]).collect(); diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/render_settings.rs index c59411b3..dfa62a7f 100644 --- a/crates/export/tests/render_settings.rs +++ b/crates/export/tests/render_settings.rs @@ -224,7 +224,7 @@ fn render_logs() { let r = r.unwrap(); let (path, text) = log_of(&files).expect("log"); assert_eq!(r.log.as_deref(), Some(path.as_str())); - assert_eq!(path, "/l/b_RenderLog.txt"); + assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/b_RenderLog.txt")); assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}"); assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}"); assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}"); @@ -239,7 +239,7 @@ fn render_logs() { let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false); assert!(r.is_err()); let (path, text) = log_of(&files).expect("error log"); - assert_eq!(path, "/l/d_RenderLog.txt"); + assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/d_RenderLog.txt")); assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}"); // A user stop is not an error: no log. let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true); @@ -255,7 +255,7 @@ struct Quota { impl StorageQuota for Quota { fn has_room(&self, path: &str, bytes: u64) -> bool { - if !path.starts_with("/primary/") { + if !std::path::Path::new(path).starts_with("/primary") { return true; } let mut u = self.used.lock().unwrap(); @@ -278,7 +278,14 @@ fn storage_overflow_moves_files_to_the_overflow_folder() { let r = r.unwrap(); assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow); let f = files.lock().unwrap(); - assert_eq!(f.iter().filter(|(p, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10); + assert_eq!( + f.iter() + .filter(|(p, _)| std::path::Path::new(p).parent() == Some(std::path::Path::new("/overflow")) + && std::path::Path::new(p).file_name().is_some_and(|n| n.to_string_lossy().starts_with("seq_")) + && p.ends_with(".png")) + .count(), + 10 + ); let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap(); assert!(log.contains("Storage overflow: 10 file(s)"), "{log}"); drop(f); @@ -288,7 +295,7 @@ fn storage_overflow_moves_files_to_the_overflow_folder() { let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() }; let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false); assert!(r.unwrap().overflow.is_empty()); - assert!(files.lock().unwrap().iter().all(|(p, _)| p.starts_with("/primary/"))); + assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(p).starts_with("/primary"))); } #[test] diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 4c2f6bb4..cf533031 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -482,7 +482,7 @@ fn wav_and_aiff_audio_only() { } else { // AIFF: big-endian samples after the SSND chunk's offset/block size. let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16; - let left = bytes[at..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0); + let left = bytes[at..].as_chunks::<4>().0.iter().skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0); rms(left) }; assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}"); diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f64f5e2d..1b059f47 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -387,9 +387,11 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option { if cb.len() != (s.width * s.height * 8) as usize || zb.len() != (s.width * s.height * 4) as usize { return None; } - let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect(); + let color = cb.as_chunks::<8>().0.iter().map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect(); let depth = zb - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|c| { let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); if z < 0.0 { f32::INFINITY } else { z } @@ -765,7 +767,9 @@ pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option() + .0 + .iter() .map(|c| { let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); if z >= BIG * 0.5 { f32::INFINITY } else { z } @@ -812,7 +816,9 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc() + .0 + .iter() .map(|c| { let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); if z >= BIG * 0.5 { f32::INFINITY } else { z } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d252dcf6..0230e560 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -509,6 +509,12 @@ impl GpuContext { pub async fn request() -> Result { #[allow(unused_mut)] let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + #[cfg(not(target_arch = "wasm32"))] + { + // Match eframe's backend override so CLI and headless acceptance tests + // exercise the backend requested for the native viewer. + desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends); + } #[cfg(target_arch = "wasm32")] { desc.backends = wgpu::Backends::BROWSER_WEBGPU; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 151039aa..c9e0900e 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -97,7 +97,7 @@ pub fn warp_q( p.f[0] = r[0]; p.f[1] = r[1]; p.f[2] = r[2]; - p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), 0.0]; + p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v)]; p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32]; let mut out = e.scratch(dst.width, dst.height); e.pixels("warp_blend", &p, &img, Some(dst), &out, None); @@ -110,7 +110,7 @@ pub fn warp_q( pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32, levels: Option) -> GpuImage { let mut p = Params::default(); p.u[0] = [mode_id(mode), 0, seed, 0]; - p.f[0] = [opacity, levels.unwrap_or(0.0), 0.0, 0.0]; + p.f[0] = [opacity, levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v), 0.0]; let mut out = e.scratch(dst.width, dst.height); e.pixels("blend_full", &p, src, Some(dst), &out, None); out.levels = levels; @@ -174,6 +174,7 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { } let mut p = Params::default(); p.f[0][0] = levels; + p.f[0][1] = 1.0 / levels; let mut out = e.scratch(img.width, img.height); e.pixels("quantize", &p, img, None, &out, None); out.levels = Some(levels); diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index d06a52af..70aaee9f 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -233,8 +233,10 @@ fn sim_key(req: &SimRequest) -> u128 { /// Live particles of a state buffer's bytes. fn parse(bytes: &[u8]) -> Vec { - let f: Vec = bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); - f.chunks_exact(12) + let f: Vec = bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); + f.as_chunks::<12>() + .0 + .iter() .enumerate() .filter(|(_, s)| s[9] != 0.0) .map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 }) diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 261e8aca..a28ecc65 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -2,10 +2,17 @@ // the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed // `fxk_` (the family files share one module). +// Correct the approximate quotient before bitwise/threshold operations. +fn fxk_div3(a: vec3, b: f32) -> vec3 { + let q = a / b; + let r = fma(-q, vec3(b), a); + return fma(r, vec3(1.0 / b), q); +} + // util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6. fn fxk_straight(px: vec4) -> vec3 { if (px.w > 1e-6) { - return px.xyz / px.w; + return fxk_div3(px.xyz, px.w); } return vec3(0.0); } @@ -457,7 +464,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, px); return; } - let s = px.xyz / px.w; + let s = fxk_div3(px.xyz, px.w); var r = vec3(0.0); for (var i = 0u; i < 3u; i++) { let v = s[i]; diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 3d49ab4d..28439067 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -15,11 +15,14 @@ fn fxn_pixel(gid: vec3) -> vec2 { } // a / b rounded like the CPU's IEEE division (GPU division may be approximate): one -// residual correction with fma. Used where a threshold follows the quotient. +// residual correction with fma, followed by a second refinement for half-step cases. +// Used where a strict threshold follows the quotient. fn fxn_div4(a: vec4, b: vec4) -> vec4 { let q = a / b; let r = fma(-q, b, a); - return fma(r, 1.0 / b, q); + let corrected = fma(r, 1.0 / b, q); + let residual = fma(-corrected, b, a); + return fma(residual, 1.0 / b, corrected); } // util::unpremul: (straight colour, alpha). diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 68b693cf..82ccbb57 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -453,10 +453,15 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { u = bu + (u - bu) * persp; v = bv + (v - bv) * persp; } - if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) { + // The CPU's 1e-9 edge allowance disappears in f32 at 1.0. Allow eight + // f32 ULPs for the inverse solve so exact quad-edge pixels remain covered. + let edge = 0.00000095367431640625; + if (u < -edge || u > 1.0 + edge || v < -edge || v > 1.0 + edge) { textureStore(out, p, vec4(0.0)); return; } + u = clamp(u, 0.0, 1.0); + v = clamp(v, 0.0, 1.0); let rc = P.f[5]; let lx = rc.x + u * rc.z; let ly = rc.y + v * rc.w; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 858d8741..8576fdf9 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if ((flags & 2u) != 0u) { d = vec4(0.0); } - store_q(vec2(x, y), d, P.f[3].z); + store_q(vec2(x, y), d, P.f[3].z, P.f[3].w); return; } let r0 = P.f[0]; @@ -52,7 +52,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (stencil && (flags & 1u) == 0u) { d = blend_pixel(mode, d, vec4(0.0), 0.5); } - store_q(vec2(x, y), d, P.f[3].z); + store_q(vec2(x, y), d, P.f[3].z, P.f[3].w); return; } var s = sample(src, sampling, px, py); @@ -60,11 +60,11 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (s.w > 0.0) { d = d + s * P.f[3].y; } - store_q(vec2(x, y), d, P.f[3].z); + store_q(vec2(x, y), d, P.f[3].z, P.f[3].w); return; } if (s.w <= 0.0 && !stencil) { - store_q(vec2(x, y), d, P.f[3].z); + store_q(vec2(x, y), d, P.f[3].z, P.f[3].w); return; } let op = P.f[3].x; @@ -75,7 +75,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3) { if (mode == 1u || mode == 2u) { n = hash_noise(u32(x), u32(y), seed); } - store_q(vec2(x, y), blend_pixel(mode, d, s, n), P.f[3].z); + store_q(vec2(x, y), blend_pixel(mode, d, s, n), P.f[3].z, P.f[3].w); } // Blend `src` (same size) onto `aux` (Image::blend_from). @@ -91,7 +91,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3) { let d = textureLoad(aux, p, 0); var s = textureLoad(src, p, 0); if (s.w <= 0.0 && !is_stencil(mode)) { - store_q(p, d, P.f[0].y); + store_q(p, d, P.f[0].y, P.f[0].z); return; } s = s * P.f[0].x; @@ -99,7 +99,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3) { if (mode == 1u || mode == 2u) { n = hash_noise(gid.x, gid.y, P.u[0].z); } - store_q(p, blend_pixel(mode, d, s, n), P.f[0].y); + store_q(p, blend_pixel(mode, d, s, n), P.f[0].y, P.f[0].z); } // Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma, @@ -179,24 +179,24 @@ fn quantize(@builtin(global_invocation_id) gid: vec3) { if (p.x >= dims.x || p.y >= dims.y) { return; } - textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x)); + textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x, P.f[0].y)); } -fn quantize_px(s: vec4, levels: f32) -> vec4 { - let inv = 1.0 / levels; - let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv; +fn quantize_px(s: vec4, levels: f32, inv: f32) -> vec4 { + // Use the CPU-rounded reciprocal to avoid backend-dependent division. + let a = floor(clamp(s.w, 0.0, 1.0) * levels + 0.5) * inv; var o = vec4(0.0, 0.0, 0.0, a); - o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv; - o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv; - o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv; + o.x = floor(clamp(s.x, 0.0, a) * levels + 0.5) * inv; + o.y = floor(clamp(s.y, 0.0, a) * levels + 0.5) * inv; + o.z = floor(clamp(s.z, 0.0, a) * levels + 0.5) * inv; return o; } // Store a compositing result, clamped and quantised to `levels` when > 0 (8/16 bpc: the comp // after a layer, fused into the layer's composite). -fn store_q(p: vec2, v: vec4, levels: f32) { +fn store_q(p: vec2, v: vec4, levels: f32, inv: f32) { if (levels > 0.0) { - textureStore(out, p, quantize_px(v, levels)); + textureStore(out, p, quantize_px(v, levels, inv)); } else { textureStore(out, p, v); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 3c6fdfa3..1d70f992 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -580,6 +580,30 @@ fn registry_badges_match_the_gpu_implementation() { } } +#[test] +fn quantization_preserves_cpu_levels_and_half_step_neighbors() { + let Some(g) = gpu() else { return }; + for levels in [255.0_f32, 32768.0] { + let mut values = vec![0.0, 1.0]; + for i in 0..255 { + let half = (i as f32 + 0.5) / levels; + values.extend([half.next_down(), half, half.next_up()]); + } + let mut cpu = Image::new(values.len() as u32, 1); + for (p, v) in cpu.data.iter_mut().zip(values) { + *p = [v, v, v, 1.0]; + } + let input = g.ctx.upload_image(&cpu).unwrap(); + effectcraft_render::color::quantize(&mut cpu, levels); + let mut e = crate::context::Enc::new(&g.ctx); + let out = crate::ops::quantize(&mut e, &input, levels); + let bytes = e.read_texture(&out.texture, out.width, out.height, 16).unwrap(); + for (i, (want, got)) in cpu.data.iter().flatten().zip(bytes.as_chunks::<4>().0).enumerate() { + let actual = f32::from_le_bytes(*got); + assert_eq!(actual, *want, "levels {levels}, component {i}"); + } + } +} #[test] fn backend_selection_and_display_frames() { let Some(g) = gpu() else { return }; @@ -600,7 +624,7 @@ fn backend_selection_and_display_frames() { let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame"); let bytes = g.read_display(&f).expect("readback"); let cpu = Renderer::new(&s.p, &Pattern, opts()).comp_frame_cpu(s.cid, Tick::ZERO); - for (p, q) in cpu.data.iter().zip(bytes.chunks_exact(4)) { + for (p, q) in cpu.data.iter().zip(bytes.as_chunks::<4>().0.iter()) { let a = p[3].clamp(0.0, 1.0); let want = [p[0].clamp(0.0, a), p[1].clamp(0.0, a), p[2].clamp(0.0, a), a].map(|v| (v * 255.0 + 0.5) as u8); for k in 0..4 { diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs index 76c7e831..5ece27e6 100644 --- a/crates/gpu/src/tests_fx_key.rs +++ b/crates/gpu/src/tests_fx_key.rs @@ -228,3 +228,24 @@ fn layer_inputs() { } } } + +#[test] +fn arithmetic_preserves_positive_subepsilon_alpha() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.channel.arithmetic").unwrap(); + let size = [1.0, 1.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() }; + params.values.insert("operator".into(), Value::Enum(3)); + for key in ["redValue", "greenValue", "blueValue"] { + params.values.insert(key.into(), n(0.0)); + } + params.values.insert("clip".into(), Value::Bool(false)); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 0, adjustment: false, env: Default::default() }; + let img = effectcraft_raster::Image::filled(1, 1, [1.0, 0.0, 0.0, 5e-7]); + let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap(); + assert_eq!(cpu.img.get(0, 0), [1.0, 0.0, 0.0, 5e-7]); + assert_eq!(out.img.get(0, 0), cpu.img.get(0, 0)); +} diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs index 50d84780..edb0b035 100644 --- a/crates/gpu/src/tests_fx_noise.rs +++ b/crates/gpu/src/tests_fx_noise.rs @@ -202,3 +202,26 @@ fn echo_and_posterize_time() { } } } + +#[test] +fn dust_threshold_on_quantized_input() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.noise.dustscratches").unwrap(); + let size = [70.0, 44.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() }; + params.values.insert("radius".into(), n(3.0)); + params.values.insert("threshold".into(), n(10.0)); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.25, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + for seed in 1..32 { + let mut img = crate::tests::pattern(seed, 70, 44); + effectcraft_render::color::quantize(&mut img, 255.0); + let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap(); + for (i, (a, b)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() { + let d = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0_f32, f32::max); + assert!(d < 1e-6, "seed {seed} pixel {i} input {:?} CPU {a:?} GPU {b:?}", buf.img.data[i]); + } + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 54e2f602..b4ca00a4 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -229,8 +229,10 @@ mod tests { s.poll_render(); let mut got = got.lock().unwrap().clone(); got.sort_by(|a, b| a.0.cmp(&b.0)); - let names: Vec<&str> = got.iter().map(|(p, _)| p.as_str()).collect(); - assert_eq!(names, [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]); + let names: Vec = got.iter().map(|(p, _)| std::path::PathBuf::from(p)).collect(); + let expected = [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")] + .map(|p| std::path::absolute(p).unwrap()); + assert_eq!(names, expected); assert!(got[0].1.starts_with(b"GIF89a")); assert!(got[1].1.starts_with(b"\x89PNG")); assert!(!std::path::Path::new(dir).exists()); diff --git a/crates/keyframe/examples/ae_ease_samples.rs b/crates/keyframe/examples/ae_ease_samples.rs new file mode 100644 index 00000000..4063bec0 --- /dev/null +++ b/crates/keyframe/examples/ae_ease_samples.rs @@ -0,0 +1,34 @@ +//! Original scalar keyframe cases for comparison with AE through AEsync. +use effectcraft_keyframe::{Ease, Interp, Keyframe, Value, evaluate}; +use effectcraft_time::Tick; +use serde_json::json; + +fn main() -> Result<(), Box> { + let times: Vec = (0..=20).map(|i| f64::from(i) / 20.0).collect(); + let mut cases = Vec::new(); + for (name, from, to, out_speed, out_inf, in_speed, in_inf) in [ + ("easy", 0.0, 100.0, 0.0, 1.0 / 3.0, 0.0, 1.0 / 3.0), + ("high_influence", 0.0, 100.0, 0.0, 0.8, 0.0, 0.8), + ("asymmetric", 0.0, 100.0, 20.0, 0.8, 80.0, 0.6), + ("overshoot", 0.0, 100.0, 400.0, 0.4, -100.0, 0.4), + ("descending", 100.0, 0.0, -20.0, 0.6, -80.0, 0.8), + ("equal_endpoints", 50.0, 50.0, 100.0, 0.4, -100.0, 0.4), + ("maximum_influence", 0.0, 100.0, 0.0, 1.0, 0.0, 1.0), + ("minimum_maximum", 0.0, 100.0, 0.0, 0.001, 0.0, 1.0), + ] { + let mut a = Keyframe::new(Tick::ZERO, Value::Scalar(from)); + let mut b = Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(to)); + a.out_interp = Interp::Bezier; + b.in_interp = Interp::Bezier; + a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }]; + b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }]; + let keys = [a, b]; + let values: Vec = times + .iter() + .map(|t| evaluate(&keys, Tick::from_seconds_f64(*t), false).map(|v| v.as_f64()).ok_or("missing keyframe evaluation")) + .collect::>()?; + cases.push(json!({"name":name,"from":from,"to":to,"outSpeed":out_speed,"outInfluence":out_inf * 100.0,"inSpeed":in_speed,"inInfluence":in_inf * 100.0,"times":times,"values":values})); + } + println!("{}", serde_json::to_string_pretty(&json!({"cases":cases}))?); + Ok(()) +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 559c6860..7b0f3021 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -179,16 +179,22 @@ fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 { if x >= x3 { return 1.0; } + if x1 == x3 && x2 == x0 { + // Full influence on both sides has a flat time derivative at the midpoint. + // Its exact inverse avoids cancellation and residual-only Newton convergence there. + let progress = (x - x0) / (x3 - x0); + return 0.5 + ((progress - 0.5) * 0.25).cbrt(); + } let mut u = (x - x0) / (x3 - x0); for _ in 0..8 { let f = bez(x0, x1, x2, x3, u) - x; - if f.abs() < 1e-12 { - return u; - } let d = bez_d(x0, x1, x2, x3, u); if d.abs() < 1e-12 { break; } + if (f / d).abs() < 1e-12 { + return u; + } let n = u - f / d; if !(0.0..=1.0).contains(&n) { break; @@ -210,12 +216,8 @@ fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 { /// Fraction of progress 0..1 through a 1D Bezier temporal segment whose value goes from 0 to 1, /// with normalised out/in speeds (speed × duration / Δvalue) and influences. pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f64, x: f64) -> f64 { - let (mut i0, mut i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0)); - if i0 + i1 > 1.0 { - let k = 1.0 / (i0 + i1); - i0 *= k; - i1 *= k; - } + // The time handles may cross. AE keeps both influences; rescaling them changes the ease. + let (i0, i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0)); let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x); bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u) } @@ -289,7 +291,8 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - if keys[i].spatial_auto { auto_spatial(keys, i) } else { (keys[i].spatial_in, keys[i].spatial_out) } + let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). @@ -342,11 +345,12 @@ pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool /// Arc length of the spatial segment `i` → `i + 1` (motion-path length). pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 { - let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return 0.0 }; + let Some(next) = i.checked_add(1) else { return 0.0 }; + let (Some(a), Some(b)) = (keys.get(i), keys.get(next)) else { return 0.0 }; let p0 = v3(&a.value); let p3 = v3(&b.value); let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (in_t, _) = spatial_tangents(keys, next); SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length() } @@ -388,7 +392,9 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { let (t0, t1) = (keys[a].time, keys[b].time); for (o, i) in (a + 1..b).enumerate() { let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; - let nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() as i64); + let span = i128::from(t1.0) - i128::from(t0.0); + let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64); if nt != keys[i].time { keys[i].time = nt; changed = true; @@ -408,7 +414,9 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { pub fn time_reverse(keys: &mut [Keyframe]) { let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return }; for k in keys.iter_mut() { - k.time = Tick(first.0 + last.0 - k.time.0); + // Imported Tick values may span i64's full range; keep the intermediate exact. + let reversed = i128::from(first.0) + i128::from(last.0) - i128::from(k.time.0); + k.time = Tick(reversed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64); std::mem::swap(&mut k.in_interp, &mut k.out_interp); std::mem::swap(&mut k.in_ease, &mut k.out_ease); std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); @@ -480,12 +488,7 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) }; let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) }; // Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur). - let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0)); - if i0 + i1 > 1.0 { - let k = 1.0 / (i0 + i1); - i0 *= k; - i1 *= k; - } + let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0)); let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x); let v = bez(ca[d], ca[d] + eo.speed * i0 * dur, cb[d] - ei.speed * i1 * dur, cb[d], u); out.push(v); @@ -522,6 +525,8 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_in = old.spatial_in; k.spatial_out = old.spatial_out; k.spatial_auto = old.spatial_auto; + k.spatial_continuous = old.spatial_continuous; + k.roving = old.roving; k.label = old.label; keys[i] = k; i @@ -560,6 +565,94 @@ mod tests { Tick::from_seconds_f64(x) } + #[test] + fn missing_spatial_keys_have_no_tangents_or_length() { + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))]; + for i in [1, usize::MAX] { + assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_segment_length(&keys, i), 0.0); + } + assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0); + } + + #[test] + fn time_reverse_handles_deserialized_tick_extremes() { + // Tick's public serde representation can contain values beyond from_seconds_f64's cap. + for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] { + let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))]; + time_reverse(&mut keys); + assert_eq!(keys[0].time, Tick(first)); + assert_eq!(keys[0].value, Value::Scalar(2.0)); + assert_eq!(keys[1].time, Tick(last)); + assert_eq!(keys[1].value, Value::Scalar(1.0)); + time_reverse(&mut keys); + assert_eq!(keys[0].value, Value::Scalar(1.0)); + } + } + + #[test] + fn roving_handles_deserialized_tick_extremes() { + let mut keys = vec![ + Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])), + Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])), + Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])), + ]; + for key in &mut keys { + key.spatial_auto = false; + } + keys[1].roving = true; + assert!(retime_roving(&mut keys, true)); + assert_eq!(keys[1].time, Tick::ZERO); + } + + #[test] + fn replacing_key_preserves_spatial_metadata() { + let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0])); + key.roving = true; + key.spatial_continuous = true; + let mut keys = vec![key]; + set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0]))); + assert!(keys[0].roving); + assert!(keys[0].spatial_continuous); + assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0])); + } + + #[test] + fn temporal_ease_matches_live_ae_with_overlapping_influences() { + // Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05. + // AE retains each handle's requested influence even when their sum exceeds 100%. + for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [ + (0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]), + (0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]), + (100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]), + ] { + let mut a = Keyframe::new(s(0.0), Value::Scalar(from)); + let mut b = Keyframe::new(s(1.0), Value::Scalar(to)); + a.out_interp = Interp::Bezier; + b.in_interp = Interp::Bezier; + a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }]; + b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }]; + for (time, expected) in samples { + let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64(); + assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}"); + let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time); + assert!((from + normalized * (to - from) - expected).abs() < 1e-7); + } + } + } + + #[test] + fn maximum_influence_resolves_flat_midpoint_time_curve() { + for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] { + // At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly. + let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt(); + let expected = bez(0.0, 0.0, 1.0, 1.0, u); + let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time); + assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}"); + } + } + /// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns. #[test] fn gradients_coerce_from_plain_json() { diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 8101b586..e3264e3b 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -145,12 +145,8 @@ fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec, Vec 1.0 { - let k = 1.0 / (i0 + i1); - i0 *= k; - i1 *= k; - } + // Crossing time handles must retain the independently specified influences. + let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0)); let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) }; ox.push(r6(i0)); oy.push(r6(y0)); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 9c92852d..a677def1 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -197,6 +197,10 @@ fn easing_converts_exactly_both_ways() { assert!((k0["o"]["x"][0].as_f64().unwrap() - 0.6).abs() < 1e-9); assert!((k0["o"]["y"][0].as_f64().unwrap() - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5); assert_eq!(json["layers"][0]["ks"]["r"]["k"][1]["h"], json!(1)); + let scale = &json["layers"][0]["ks"]["s"]["k"][0]; + assert!((scale["o"]["x"][0].as_f64().unwrap() - 0.9).abs() < 1e-9); + assert!((scale["i"]["x"][0].as_f64().unwrap() - 2.0 / 3.0).abs() < 1e-6); + assert!((scale["o"]["y"][0].as_f64().unwrap() + 0.1125).abs() < 1e-9); let rq = &q.comp(nc).unwrap().layers[0]; let rk = &rq.props.prop("transform/rotation").unwrap().keys; assert!((rk[0].out_ease[0].speed - 300.0).abs() < 1e-3 && (rk[0].out_ease[0].influence - 0.6).abs() < 1e-6, "{:?}", rk[0].out_ease); diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 1edcf5fd..39fa386d 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -60,7 +60,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - PixelData::Rgba8(d) => { img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { let src = &d[y * w * 4..(y + 1) * w * 4]; - for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) { + for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { *o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]); } }); @@ -69,7 +69,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - // linear premultiplied → encoded premultiplied img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { let src = &d[y * w * 4..(y + 1) * w * 4]; - for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) { + for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { let a = s[3]; let inv = if a > 0.0 { 1.0 / a } else { 0.0 }; let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a]; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index e5be90a6..48db82d8 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -301,7 +301,7 @@ impl MediaPool { f.seek(SeekFrom::Start(s0 as u64 * 8)).ok()?; let mut bytes = vec![0u8; n * 8]; f.read_exact(&mut bytes).ok()?; - for (o, c) in out.iter_mut().zip(bytes.chunks_exact(4)) { + for (o, c) in out.iter_mut().zip(bytes.as_chunks::<4>().0.iter()) { *o = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); } Some(out) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index fa2e930a..20578d4f 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -393,7 +393,9 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let inverse_bind = match idx(s, "inverseBindMatrices") { Some(a) => { let (f, _) = d.floats(a)?; - f.chunks_exact(16) + f.as_chunks::<16>() + .0 + .iter() .map(|v| { let mut m = [[0.0; 4]; 4]; for c in 0..4 { @@ -495,29 +497,29 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result = pos.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + let positions: Vec<[f32; 3]> = pos.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); let n = positions.len(); let vec3s = |a: Option| -> Result> { Ok(match a { - Some(a) => d.floats(a)?.0.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(), + Some(a) => d.floats(a)?.0.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(), None => vec![], }) }; let mut normals = vec3s(idx(attrs, "NORMAL"))?; let uvs: Vec<[f32; 2]> = match idx(attrs, "TEXCOORD_0") { - Some(a) => d.floats(a)?.0.chunks_exact(2).map(|c| [c[0], c[1]]).collect(), + Some(a) => d.floats(a)?.0.as_chunks::<2>().0.iter().map(|c| [c[0], c[1]]).collect(), None => vec![], }; let tangents: Vec<[f32; 4]> = match idx(attrs, "TANGENT") { - Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(), + Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(), None => vec![], }; let joints: Vec<[u16; 4]> = match idx(attrs, "JOINTS_0") { - Some(a) => d.ints(a)?.chunks_exact(4).map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(), + Some(a) => d.ints(a)?.as_chunks::<4>().0.iter().map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(), None => vec![], }; let weights: Vec<[f32; 4]> = match idx(attrs, "WEIGHTS_0") { - Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(), + Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(), None => vec![], }; let raw: Vec = match idx(p, "indices") { @@ -540,7 +542,7 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result indices.extend(raw.chunks_exact(3).flatten()), + _ => indices.extend(raw.as_chunks::<3>().0.iter().flatten()), } if normals.len() != n { normals.clear(); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 144f4e8e..9480f29c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -169,7 +169,7 @@ impl Primitive { /// Smooth vertex normals from the triangles (area weighted), for meshes without normals. pub fn compute_normals(&mut self) { let mut n = vec![[0.0f32; 3]; self.positions.len()]; - for t in self.indices.chunks_exact(3) { + for t in self.indices.as_chunks::<3>().0 { let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]); let f = cross(sub(b, a), sub(c, a)); for &i in t { @@ -185,7 +185,7 @@ impl Primitive { /// primitive has none). pub fn flat_normals(&mut self) { let mut p = Primitive { material: self.material, ..Default::default() }; - for t in self.indices.chunks_exact(3) { + for t in self.indices.as_chunks::<3>().0 { let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]); let f = normalize(cross(sub(b, a), sub(c, a))).unwrap_or([0.0, 0.0, 1.0]); for &i in t { @@ -213,7 +213,7 @@ impl Primitive { } let mut tan = vec![[0.0f32; 3]; n]; let mut bit = vec![[0.0f32; 3]; n]; - for t in self.indices.chunks_exact(3) { + for t in self.indices.as_chunks::<3>().0 { let (i0, i1, i2) = (t[0] as usize, t[1] as usize, t[2] as usize); let (p0, p1, p2) = (self.positions[i0], self.positions[i1], self.positions[i2]); let (w0, w1, w2) = (self.uvs[i0], self.uvs[i1], self.uvs[i2]); diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs index e6727e73..059278ec 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -205,7 +205,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let (_, prim, map, need) = &mut prims[slot]; let mut ids = vec![]; for (vi, ti, ni) in refs { - let k = Key { v: vi, t: ti.map_or(u32::MAX, |x| x), n: ni.map_or(u32::MAX, |x| x), s }; + let k = Key { v: vi, t: ti.unwrap_or(u32::MAX), n: ni.unwrap_or(u32::MAX), s }; let id = *map.entry(k).or_insert_with(|| { prim.positions.push(v[vi as usize]); prim.uvs.push(ti.map_or([0.0, 0.0], |t| vt[t as usize])); diff --git a/crates/opusenc/src/rate.rs b/crates/opusenc/src/rate.rs index d8665bf1..a6ae81f5 100644 --- a/crates/opusenc/src/rate.rs +++ b/crates/opusenc/src/rate.rs @@ -198,9 +198,9 @@ fn compute_caps(m: &Mode) -> Vec { for c in 1..=2i32 { for j in 0..nb { let mut n0 = EBANDS[j + 1] - EBANDS[j]; - let max_bits; - if n0 << i == 1 { - max_bits = c * (1 + MAX_FINE_BITS) << BITRES; + + let max_bits = if n0 << i == 1 { + c * (1 + MAX_FINE_BITS) << BITRES } else { let mut lm0 = 0; if n0 > 2 { @@ -241,8 +241,8 @@ fn compute_caps(m: &Mode) -> Vec { let den = (ndof - 1) << BITRES; let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS); mb += c * qb << BITRES; - max_bits = mb; - } + mb + }; let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i); let v = (4 * max_bits / w) - 64; caps[((i * 2 + c - 1) as usize) * nb + j] = v.clamp(0, 255) as u8; diff --git a/crates/opusenc/tests/ffmpeg.rs b/crates/opusenc/tests/ffmpeg.rs index 672426cc..fcbb8c8a 100644 --- a/crates/opusenc/tests/ffmpeg.rs +++ b/crates/opusenc/tests/ffmpeg.rs @@ -38,7 +38,7 @@ fn ffmpeg_decode(exe: &str, codec: &str, file: &[u8], channels: usize, tag: &str // timestamping, not the bitstream, so it is the one message tolerated. let errors: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty() && !l.contains("non monotonically increasing dts to muxer")).collect(); assert!(errors.is_empty(), "ffmpeg ({codec}) reported errors: {errors:?}"); - Some(out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()) + Some(out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()) } #[test] diff --git a/crates/path/src/offset.rs b/crates/path/src/offset.rs index c0ec49d9..3154bf8d 100644 --- a/crates/path/src/offset.rs +++ b/crates/path/src/offset.rs @@ -122,7 +122,7 @@ fn deep_point(bp: &BezPath) -> Option { _ => {} }); xs.sort_by(f64::total_cmp); - for w in xs.chunks_exact(2) { + for w in xs.as_chunks::<2>().0 { let span = w[1] - w[0]; if best.is_none_or(|(b, _)| span > b) { best = Some((span, kurbo::Point::new((w[0] + w[1]) / 2.0, y))); diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index ce39a732..1c8c603e 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -733,7 +733,7 @@ impl T2<'_> { } 5 => { let s = self.args(); - for c in s.chunks_exact(2) { + for c in s.as_chunks::<2>().0 { self.line_to(c[0], c[1]); } } @@ -751,7 +751,7 @@ impl T2<'_> { } 8 => { let s = self.args(); - for c in s.chunks_exact(6) { + for c in s.as_chunks::<6>().0 { self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); } } @@ -759,7 +759,7 @@ impl T2<'_> { // rcurveline let s = self.args(); let nc = s.len().saturating_sub(2) / 6; - for c in s[..nc * 6].chunks_exact(6) { + for c in s[..nc * 6].as_chunks::<6>().0 { self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); } if s.len() >= nc * 6 + 2 { @@ -771,7 +771,7 @@ impl T2<'_> { let s = self.args(); if s.len() >= 6 { let nl = (s.len() - 6) / 2; - for c in s[..nl * 2].chunks_exact(2) { + for c in s[..nl * 2].as_chunks::<2>().0 { self.line_to(c[0], c[1]); } let c = &s[nl * 2..]; @@ -785,7 +785,7 @@ impl T2<'_> { if s.len() % 4 == 1 { dx1 = s.remove(0); } - for c in s.chunks_exact(4) { + for c in s.as_chunks::<4>().0 { self.curve([dx1, c[0], c[1], c[2], 0.0, c[3]]); dx1 = 0.0; } @@ -797,7 +797,7 @@ impl T2<'_> { if s.len() % 4 == 1 { dy1 = s.remove(0); } - for c in s.chunks_exact(4) { + for c in s.as_chunks::<4>().0 { self.curve([c[0], dy1, c[1], c[2], c[3], 0.0]); dy1 = 0.0; } diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 6b4e4ae7..d450ea0d 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -560,7 +560,7 @@ impl Func { /// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1). pub fn text_string(b: &[u8]) -> String { if b.starts_with(&[0xFE, 0xFF]) { - let u: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = b[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); return String::from_utf16_lossy(&u); } if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index e2ef1693..296878c3 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -231,21 +231,21 @@ fn parse_cmap(data: &[u8]) -> CMap { let a = std::mem::take(&mut args); match mode { "cs" => { - for p in a.chunks_exact(2) { + for p in a.as_chunks::<2>().0 { if let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) { m.codespace.push((lo.len().max(1), be(lo), be(hi))); } } } "bfchar" => { - for p in a.chunks_exact(2) { + for p in a.as_chunks::<2>().0 { if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { m.unicode.insert(be(src), utf16(dst)); } } } "bfrange" => { - for p in a.chunks_exact(3) { + for p in a.as_chunks::<3>().0 { let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) else { continue }; let (lo, hi) = (be(lo), be(hi)); if hi < lo || hi - lo > 65535 { @@ -273,7 +273,7 @@ fn parse_cmap(data: &[u8]) -> CMap { } } "cidchar" => { - for p in a.chunks_exact(2) { + for p in a.as_chunks::<2>().0 { if let (Obj::Str(src), Some(cid)) = (&p[0], p[1].num()) { let c = be(src); m.cids.push((c, c, cid as u32)); @@ -281,7 +281,7 @@ fn parse_cmap(data: &[u8]) -> CMap { } } "cidrange" => { - for p in a.chunks_exact(3) { + for p in a.as_chunks::<3>().0 { if let (Obj::Str(lo), Obj::Str(hi), Some(cid)) = (&p[0], &p[1], p[2].num()) { m.cids.push((be(lo), be(hi), cid as u32)); } @@ -431,7 +431,7 @@ impl Font { match w.get(i + 1).map(|x| file.resolve(x)) { Some(Obj::Array(list)) => { let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); - for (k, t) in v.chunks_exact(3).enumerate() { + for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); } i += 2; @@ -468,7 +468,7 @@ impl Font { } match file.get(&desc_dict, "CIDToGIDMap") { Some(Obj::Stream(sd, raw)) => { - f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); + f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); } _ => f.cid_to_gid = None, } diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index f0adaab2..3c011dc4 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -561,7 +561,9 @@ impl Interp { _ => vec![], }; let m = self.gs.ctm; - nums.chunks_exact(4) + nums.as_chunks::<4>() + .0 + .iter() .map(|r| { let mut p = BezPath::new(); p.move_to(m * Point::new(r[0], r[1])); @@ -656,7 +658,7 @@ impl Interp { let items = self.st.split_off(self.st.len() - k); self.st.pop(); let d = new_dict(); - for kv in items.chunks_exact(2) { + for kv in items.as_chunks::<2>().0 { d.borrow_mut().insert(kv[0].key(), kv[1].clone()); } self.push(V::Dict(d)); diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs index b9e680cf..f489ad2a 100644 --- a/crates/pdf/src/type1.rs +++ b/crates/pdf/src/type1.rs @@ -116,7 +116,7 @@ impl Type1 { let hex = body.iter().take(4).all(u8::is_ascii_hexdigit); let raw: Vec = if hex { let digits: Vec = body.iter().copied().filter(u8::is_ascii_hexdigit).collect(); - digits.chunks_exact(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() + digits.as_chunks::<2>().0.iter().map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() } else { body.to_vec() }; diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs index 71f1f136..1f61c6b4 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -141,8 +141,8 @@ impl EffectPlugin for WasmPlugin { let r = match r { Ok(0) => { inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?; - for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].chunks_exact(16)) { - for (c, v) in px.iter_mut().zip(b.chunks_exact(4)) { + for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].as_chunks::<16>().0.iter()) { + for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) { *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index f6a46caa..62ee10ff 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -1530,10 +1530,10 @@ mod tests { assert_eq!(m.output_size(&c, &rs), (1280, 720)); m.crop = Crop { enabled: true, left: 480, right: 480, ..Default::default() }; assert_eq!(m.output_size(&c, &rs), (1280, 1440), "960×1080 cropped, then 1280 wide at 8:9"); - assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt"); - assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt"); + assert_eq!(std::path::PathBuf::from(log_path("/out/Main_[#####].png")), std::path::PathBuf::from("/out/Main_RenderLog.txt")); + assert_eq!(std::path::PathBuf::from(log_path("/out/a.mov")), std::path::PathBuf::from("/out/a_RenderLog.txt")); assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt"); - assert_eq!(log_path("gen/g_[###].png"), "gen/g_RenderLog.txt", "any bracketed run (M13.15)"); + assert_eq!(std::path::PathBuf::from(log_path("gen/g_[###].png")), std::path::PathBuf::from("gen/g_RenderLog.txt"), "any bracketed run (M13.15)"); } #[test] diff --git a/crates/psd/src/engine_data.rs b/crates/psd/src/engine_data.rs index c4f23038..b0a177d0 100644 --- a/crates/psd/src/engine_data.rs +++ b/crates/psd/src/engine_data.rs @@ -167,7 +167,7 @@ impl P<'_> { } } if raw.starts_with(&[0xFE, 0xFF]) { - let u: Vec = raw[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); String::from_utf16_lossy(&u).trim_end_matches('\0').to_string() } else { String::from_utf8_lossy(&raw).into_owned() diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 10d4ebbe..422c3153 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -833,7 +833,7 @@ impl Psd { 16 => { for row in v.chunks_exact_mut(w * 2).take(h) { let mut prev = 0u16; - for c in row.chunks_exact_mut(2) { + for c in row.as_chunks_mut::<2>().0 { let x = u16::from_be_bytes([c[0], c[1]]).wrapping_add(prev); c.copy_from_slice(&x.to_be_bytes()); prev = x; @@ -878,8 +878,8 @@ impl Psd { } out } - 16 => raw.chunks_exact(2).take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - 32 => raw.chunks_exact(4).take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), + 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + 32 => raw.as_chunks::<4>().0.iter().take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), } } diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index 0402349b..61ffbf18 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -126,8 +126,7 @@ pub fn murmur3_32(data: &[u8], seed: u32) -> u32 { const C1: u32 = 0xcc9e_2d51; const C2: u32 = 0x1b87_3593; let mut h = seed; - let chunks = data.chunks_exact(4); - let tail = chunks.remainder(); + let (chunks, tail) = data.as_chunks::<4>(); for c in chunks { let mut k = u32::from_le_bytes([c[0], c[1], c[2], c[3]]); k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 98bb9666..abcea9fa 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -236,7 +236,7 @@ fn mix_into( continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.chunks_exact(2).enumerate() { + for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { out[(a + i) * 2] += f[0] * ll; out[(a + i) * 2 + 1] += f[1] * lr; } @@ -257,7 +257,7 @@ pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { .chunks(bin * 2) .map(|c| { let mut s = [f32::MAX, f32::MIN, f32::MAX, f32::MIN]; - for f in c.chunks_exact(2) { + for f in c.as_chunks::<2>().0 { s[0] = s[0].min(f[0]); s[1] = s[1].max(f[0]); s[2] = s[2].min(f[1]); diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index 0da0288f..7d7e28ae 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -46,15 +46,15 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { set(l, "transform/scale", Value::Vec3([150.0, 150.0, 100.0])); set(l, "transform/rotation", Value::Scalar(20.0)); }; - let cid; - if flatten { + + let cid = if flatten { let mut null = build::layer(&mut p, &outer, "Null", LayerSource::Null, (100, 100), None); xf(&mut null); a.parent = Some(null.id); b.parent = Some(null.id); let mut c = outer.clone(); c.layers = vec![null, b, a, bg]; - cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) } else { let mut inner = inner; inner.layers = vec![b, a]; @@ -64,8 +64,8 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { pre.switches.collapse = collapse; let mut c = outer.clone(); c.layers = vec![pre, bg]; - cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); - } + p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) + }; (p, cid) } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 79b5dc16..4abbdddd 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -326,7 +326,7 @@ impl Builder { } // Mirroring transforms reverse the winding. let flip = flip_count * 2 > p.positions.len(); - for t in p.indices.chunks_exact(3) { + for t in p.indices.as_chunks::<3>().0 { let (a, b, c) = (t[0] + base, t[1] + base, t[2] + base); if flip { self.s.indices.extend([a, c, b]) @@ -340,7 +340,7 @@ impl Builder { /// Opaque triangles first, then transparent ones back to front. fn sort_triangles(&mut self) { - let tris: Vec<[u32; 3]> = self.s.indices.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect(); + let tris: Vec<[u32; 3]> = self.s.indices.as_chunks::<3>().0.iter().map(|t| [t[0], t[1], t[2]]).collect(); let mut order: Vec = (0..tris.len()).collect(); order.sort_by(|&a, &b| { let (ta, za) = self.tri_depth[a]; diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index c56c5e38..e04b7c33 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -196,9 +196,9 @@ pub mod sha256 { tail.push(0); } tail.extend_from_slice(&bits.to_be_bytes()); - for block in data[..data.len() - data.len() % 64].chunks_exact(64).chain(tail.chunks_exact(64)) { + for block in data[..data.len() - data.len() % 64].as_chunks::<64>().0.iter().chain(tail.as_chunks::<64>().0.iter()) { let mut w = [0u32; 64]; - for (i, c) in block.chunks_exact(4).enumerate() { + for (i, c) in block.as_chunks::<4>().0.iter().enumerate() { w[i] = u32::from_be_bytes([c[0], c[1], c[2], c[3]]); } for i in 16..64 { diff --git a/crates/segment/src/mediapipe.rs b/crates/segment/src/mediapipe.rs index ebcf13a6..eb4ab807 100644 --- a/crates/segment/src/mediapipe.rs +++ b/crates/segment/src/mediapipe.rs @@ -193,7 +193,7 @@ impl FaceLandmarker { let out = self.mesh.run(&[&x])?; let (Some(pts), Some(flag)) = (out.first(), out.get(1).and_then(|f| f.first())) else { return Err("face mesh: missing outputs".into()) }; let s = MESH_SIZE as f32; - let points = pts.chunks_exact(3).take(MESH_POINTS).map(|p| roi.to_frame(p[0] / s, p[1] / s)).collect(); + let points = pts.as_chunks::<3>().0.iter().take(MESH_POINTS).map(|p| roi.to_frame(p[0] / s, p[1] / s)).collect(); Ok(Face { points, score: sigmoid(*flag) }) } diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs index beadea94..d85ff80c 100644 --- a/crates/segment/src/pt.rs +++ b/crates/segment/src/pt.rs @@ -340,9 +340,9 @@ fn unpickle(p: &[u8]) -> Result { /// Element bytes and conversion of a storage dtype. fn to_f32(dtype: &str, bytes: &[u8]) -> Option> { match dtype { - "FloatStorage" => Some(bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()), - "HalfStorage" => Some(bytes.chunks_exact(2).map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()), - "BFloat16Storage" => Some(bytes.chunks_exact(2).map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()), + "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()), + "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()), + "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()), _ => None, } } diff --git a/crates/segment/src/tflite.rs b/crates/segment/src/tflite.rs index 4a3f6209..03a954c7 100644 --- a/crates/segment/src/tflite.rs +++ b/crates/segment/src/tflite.rs @@ -263,13 +263,13 @@ impl Model { let data = bytes.get(at..at.checked_add(len).ok_or("bad buffer")?).ok_or("truncated model")?; match ty { FLOAT32 if data.len() == 4 * count => { - floats[i] = Some(data.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()) + floats[i] = Some(data.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()) } FLOAT16 if data.len() == 2 * count => { - floats[i] = Some(data.chunks_exact(2).map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()) + floats[i] = Some(data.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()) } INT32 if data.len() == 4 * count => { - ints[i] = Some(data.chunks_exact(4).map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()) + ints[i] = Some(data.as_chunks::<4>().0.iter().map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()) } _ => return Err(format!("{}: unsupported tensor data", names[i])), } @@ -389,7 +389,9 @@ impl Model { op::PAD => { let p = ints[input(1)?].clone().ok_or("PAD: paddings must be constant")?; let pads = p - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|c| Ok([usize::try_from(c[0]).map_err(|_| "bad pad")?, usize::try_from(c[1]).map_err(|_| "bad pad")?])) .collect::>>()?; Op::Pad(pads) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index d0e62c0d..6bef8d63 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -966,7 +966,7 @@ impl<'a, 'i> Parser<'a, 'i> { } let mut p = BezPath::new(); p.move_to((nums[0], nums[1])); - for c in nums[2..].chunks_exact(2) { + for c in nums[2..].as_chunks::<2>().0 { p.line_to((c[0], c[1])); } if n.tag_name().name() == "polygon" { diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index d6c59560..7acfbd38 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -107,7 +107,7 @@ fn native_name(b: &[u8], id: u16) -> Option { continue; } let raw = b.get(storage + off..storage + off + len)?; - let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); let s = String::from_utf16_lossy(&u); if !s.trim().is_empty() { return Some(s); @@ -131,11 +131,11 @@ fn name_string(b: &[u8], id: u16) -> Option { let raw = b.get(storage + off..storage + off + len)?; let (rank, s) = match plat { 3 if enc == 1 || enc == 10 => { - let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) } 0 => { - let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); (2, String::from_utf16_lossy(&u)) } 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index b719cc8e..2efc1455 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -301,9 +301,8 @@ pub fn sym_eigen(a: &[f64], n: usize) -> (Vec, Vec) { /// Dot product, four lanes (lets the compiler vectorise). fn dot_n(a: &[f64], b: &[f64]) -> f64 { let mut acc = [0.0f64; 4]; - let (ca, cb) = (a.chunks_exact(4), b.chunks_exact(4)); - let (ra, rb) = (ca.remainder(), cb.remainder()); - for (x, y) in ca.zip(cb) { + let ((ca, ra), (cb, rb)) = (a.as_chunks::<4>(), b.as_chunks::<4>()); + for (x, y) in ca.iter().zip(cb) { for k in 0..4 { acc[k] += x[k] * y[k]; } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 4bf56800..a01a9612 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -396,6 +396,15 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma } impl Frames { + /// Actual native frame-worker capacity, including the OS fallback pool. + #[cfg(not(target_arch = "wasm32"))] + pub fn parallelism(&self) -> usize { + self.pool.as_ref().map_or_else(rayon::current_num_threads, rayon::ThreadPool::current_num_threads) + } + #[cfg(target_arch = "wasm32")] + pub fn parallelism(&self) -> usize { + self.remote_slots().max(1) + } pub fn set_context(&mut self, ctx: &egui::Context) { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); @@ -736,18 +745,21 @@ impl Worker { let t0 = web_time::Instant::now(); let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)))); let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k)); + let disk_hit = from_disk.is_some(); let ci = match from_disk { Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), - None => { - let ci = self.render_job(&job); - if let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); - } - ci - } + None => self.render_job(&job), }; - self.finish(&job, ci, t0); + // Publish the shared frame and wake the viewer before optional RGBA conversion + // and compression. Persistence still runs on this worker, using the same snapshot. + self.finish(&job, ci.clone(), t0); + if !disk_hit && let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + } true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63d1db37..d9bb8dcf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -433,14 +433,21 @@ impl EffectcraftApp { } self.gpu_checked = true; let Some(rs) = frame.wgpu_render_state() else { return }; - match effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()) { - Ok(g) => { + // Backend validation can panic on shader compilation even when device creation + // succeeded. Keep the document and the egui renderer, and use CPU compositing. + let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); + match gpu { + Ok(Ok(g)) => { log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); } - Err(e) => log::info!("GPU compositor unavailable: {e}"), + Ok(Err(e)) => log::info!("GPU compositor unavailable: {e}"), + Err(e) => { + let reason = e.downcast_ref::().map(String::as_str).or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or("backend panicked"); + log::warn!("GPU compositor setup failed; using CPU compositing: {reason}"); + } } } @@ -461,7 +468,7 @@ impl EffectcraftApp { /// and returns `None`; a later call (the next frame) has the pixels. pub fn viewer_pixels(&mut self) -> Option> { let to_image = |bytes: Vec, w: u32, h: u32| { - let px = bytes.chunks_exact(4).map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect(); + let px = bytes.as_chunks::<4>().0.iter().map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect(); Arc::new(egui::ColorImage::new([w as usize, h as usize], px)) }; if self.viewer_image.is_none() @@ -511,7 +518,7 @@ impl EffectcraftApp { // play back, and frames cached by a preview stay on screen when it stops. let full = self.ui.viewer.res.scale(zoom, ppp); // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. - let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } @@ -585,7 +592,7 @@ impl EffectcraftApp { /// Render options of viewer frames of `comp` at `scale`. pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts { - let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); let roi = self.viewer_region(comp); RenderOpts { scale, @@ -814,7 +821,9 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); 0 } else { - (self.frames_parallelism() / 2).max(2) as i64 + // Keep workers available for a newly edited viewer frame. Running obsolete + // background renders cannot be preempted by the priority queue. + self.frames_parallelism().saturating_sub(1).min(2) as i64 }; let mut queued = self.frames.inflight(); let series = self.frame_series(cid, scale); @@ -1042,7 +1051,7 @@ impl EffectcraftApp { // on the UI thread (`Frames::pump`) return self.frames.remote_slots().max(1); } - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) + self.frames.parallelism() } // ---------------------------------------------------------------- control channel @@ -1252,8 +1261,14 @@ impl EffectcraftApp { self.command_inbox = Some(rx); } menus::handle_shortcuts(self, &ctx); + if !ctx.input(|i| i.pointer.primary_down()) { + self.ui.viewer.property_interacting = false; + } let t = self.tokens; let full = ui.max_rect(); + if self.dialog.is_some() { + ui.disable(); + } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; if self.ui.show_menu_bar { @@ -1288,7 +1303,7 @@ impl EffectcraftApp { let t = &self.tokens; let galley = ui.painter().layout_no_wrap(m, Tokens::ui(12.0), t.text); let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0), galley.size() + egui::vec2(24.0, 14.0)); - ui.painter().rect_filled(r, 6.0, egui::Color32::from_rgba_premultiplied(30, 30, 30, 235)); + ui.painter().rect_filled(r, 6.0, t.panel_bg); ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text); let resp = ui.interact(r, egui::Id::new("toast"), egui::Sense::click()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c83bb03f..13292803 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -191,6 +191,35 @@ fn no_params(p: &Value) -> bool { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + if no_params(¶ms) && app.ui.focused == PanelKind::Project { + match id { + "edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})), + "edit.selectAll" => { + let items: Vec = crate::panels::project::visible_rows(app) + .into_iter() + .filter(|(id, _)| app.ui.project_search.is_empty() || app.session.project.item(*id).is_some_and(|it| !it.is_folder())) + .map(|(id, _)| id.0) + .collect(); + return run_engine(app, ctx, "project.select", json!({"items":items})); + } + "edit.deselectAll" => return run_engine(app, ctx, "project.select", json!({"items":[]})), + "edit.cut" | "edit.copy" | "edit.paste" | "edit.copyWithPropertyLinks" | "edit.copyWithRelativePropertyLinks" | "edit.copyExpressionOnly" => { + return Err("Project clipboard actions are not supported yet".into()); + } + _ => {} + } + } + // Clear follows panel focus, including the Edit menu and keyboard shortcut. + if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { + return run_engine(app, ctx, "project.delete", json!({})); + } + if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { + let selected = ctx.data(|d| d.get_temp::<(Vec, Option)>(egui::Id::new("rq-ui"))).and_then(|(_, id)| id); + return match selected { + Some(item) => run_engine(app, ctx, "renderQueue.remove", json!({"item": item})), + None => Err("select a Render Queue item first".into()), + }; + } let now = ctx.input(|i| i.time); // Closing a modified project asks to save it first; the command runs once answered. if crate::panels::unsaved::guard(app, id, ¶ms) { @@ -1117,6 +1146,26 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option } pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { + if app.ui.focused == PanelKind::Project { + match e.command.as_str() { + "edit.duplicate" => { + return !app.session.state.project_selection.is_empty() + && app.session.state.project_selection.iter().all(|id| app.session.project.item(*id).is_some_and(|i| !i.is_folder())); + } + "edit.selectAll" => return !app.session.project.items.is_empty(), + "edit.deselectAll" => return !app.session.state.project_selection.is_empty(), + "edit.cut" | "edit.copy" | "edit.paste" | "edit.copyWithPropertyLinks" | "edit.copyWithRelativePropertyLinks" | "edit.copyExpressionOnly" => { + return false; + } + _ => {} + } + } + if e.command == "edit.clear" && app.ui.focused == PanelKind::Project { + return app.session.is_enabled("project.delete"); + } + if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { + return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); + } if e.command == "window.panel" { return true; } @@ -1254,6 +1303,28 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool { /// Dispatch keyboard shortcuts (skipped while typing in a text field). pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { + // Dialog cancellation owns Escape even when a text field has keyboard focus. + if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) { + return; + } + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if app.dialog == Some(crate::Dialog::LayerStyles) { + crate::panels::layer_styles_dialog::finish(app, false); + } else { + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() + { + app.session.prefs = p; + app.session.prefs_changed(); + } + if app.dialog == Some(crate::Dialog::UnsavedChanges) { + app.dialog_state.unsaved.command = None; + } + app.dialog = None; + } + return; + } if ctx.egui_wants_keyboard_input() { return; } @@ -1310,17 +1381,18 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { // The Project panel has its own selection; a previously selected Timeline layer // must not be deleted while the Project panel has focus. if matches!(key, egui::Key::Delete | egui::Key::Backspace) && !mods.any() { - let command = if app.ui.focused == PanelKind::Project { - if app.session.state.project_selection.is_empty() { - continue; + if matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_)) && !app.session.state.camera_points.is_empty() { + if let Err(e) = run_engine(app, ctx, "camera.deletePoints", json!({})) { + app.ui.status = e; } - "project.delete" - } else { - "edit.clear" - }; - if let Err(e) = invoke(app, ctx, command, json!({})) { - app.ui.status = e; + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)); + continue; } + // The queue panel consumes the event after its selection state is drawn. + if app.ui.focused == PanelKind::RenderQueue { + continue; + } + let _ = invoke(app, ctx, "edit.clear", json!({})); continue; } if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) { diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index c9c40f05..9434f537 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -329,6 +329,7 @@ pub fn viewer_hook( } }); if resp.hovered() + && app.dialog.is_none() && !selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) && !ctx.egui_wants_keyboard_input() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d806827c..b9707b6b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -147,18 +147,18 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value) } pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { - // Dim the app. - let screen = ctx.content_rect(); - ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120)); - egui::Area::new(egui::Id::new(("modal", title))).order(egui::Order::Foreground).fixed_pos(screen.center() - size / 2.0).show(ctx, |ui| { - egui::Frame::window(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(18)).show(ui, |ui| { + let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0)); + let size = size.min(available); + egui::Modal::new(egui::Id::new(("modal", title))) + .backdrop_color(Color32::from_black_alpha(120)) + .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) + .show(ctx, |ui| { ui.set_width(size.x - 36.0); ui.set_min_height(size.y - 36.0); ui.label(egui::RichText::new(title).font(Tokens::semibold(15.0)).color(t.tab_text_active)); ui.add_space(10.0); - body(ui); + egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body); }); - }); } pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { @@ -228,11 +228,12 @@ fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } else if resp.hovered() { t.hover } else { - Color32::from_rgb(0x2b, 0x2b, 0x2b) + t.field_bg }, ); - icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, Color32::WHITE); - p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), Color32::WHITE); + let foreground = if discord { Color32::WHITE } else { t.text }; + icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, foreground); + p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), foreground); p.text( pos2(r.max.x - 12.0, r.center().y), Align2::RIGHT_CENTER, diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 5215685a..780fdf38 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1064,6 +1064,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::DragAndDrop::clear_payload(&ctx); } } + if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { + app.ui.viewer.property_interacting = true; + ctx.request_repaint(); + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index b761985e..e4874060 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -320,8 +320,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option, p: &Property, v: &KV) -> Option { let ty = ty?; let mut out = None; - let rect; - match ty { + + let rect = match ty { ControlType::Text => { let cur = match v { KV::Text(d) => d.text.clone(), @@ -339,7 +339,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ui.ctx().data_mut(|d| d.remove::(id)); } - rect = r.rect; + r.rect } ControlType::Color => { let c = v.as_color(); @@ -349,7 +349,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt let u = rgba.to_rgba_unmultiplied(); out = Some(json!([u[0], u[1], u[2], u[3]])); } - rect = r.rect; + r.rect } ControlType::Checkbox => { let mut on = v.as_bool(); @@ -357,7 +357,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if r.changed() { out = Some(json!(on)); } - rect = r.rect; + r.rect } ControlType::Dropdown => { let opts = match &p.ui { @@ -372,7 +372,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } } }); - rect = r.response.rect; + r.response.rect } ControlType::Point => { let mut a = v.as_vec2(); @@ -381,7 +381,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if r1.changed() || r2.changed() { out = Some(json!(a)); } - rect = r1.rect.union(r2.rect); + r1.rect.union(r2.rect) } ControlType::Angle => { let mut a = v.as_f64(); @@ -389,7 +389,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if r.changed() { out = Some(json!(a)); } - rect = r.rect; + r.rect } ControlType::Font => { // Family (from the installed fonts), style and size; the text stays as it is. @@ -421,7 +421,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if changed || r3.changed() { out = serde_json::to_value(&d).ok(); } - rect = r1.response.rect.union(r3.rect); + r1.response.rect.union(r3.rect) } ControlType::Scale => { // One percentage for every axis. @@ -431,7 +431,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if r.changed() { out = Some(json!(vec![x; comps.len().max(1)])); } - rect = r.rect; + r.rect } ControlType::Slider | ControlType::Media => { let mut x = v.as_f64(); @@ -442,9 +442,9 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt if r.changed() { out = Some(json!(x)); } - rect = r.rect; + r.rect } - } + }; app.auto.add(auto, rect, &p.name); out } diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 61b59a4f..904e5f2f 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -159,7 +159,7 @@ pub fn decode_thumb(text: &str) -> Option { return None; } let b: Vec = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::>().ok()?; - let px = b.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect(); + let px = b.as_chunks::<3>().0.iter().map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect(); Some(egui::ColorImage::new([THUMB_W, THUMB_H], px)) } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index f9e04ca7..b84cd8ff 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -19,6 +19,24 @@ pub struct LayerStyleState { pub layer: u64, pub selected: String, pub undo_mark: usize, + checkpoint: Option, +} + +#[derive(Clone)] +struct Checkpoint { + project: std::sync::Arc, + history: effectcraft_engine::history::History, +} + +impl std::fmt::Debug for Checkpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Checkpoint").field("undo_steps", &self.history.undo.len()).finish_non_exhaustive() + } +} + +fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { + app.session.history.merge_key = None; + Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } /// Open the dialog on the first selected (or given) layer, showing `style` (a style id; @@ -35,7 +53,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> { if style != BLENDING && !STYLES.iter().any(|s| s.0 == style) { return Err(format!("unknown layer style `{style}`")); } - app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len() }; + let checkpoint = checkpoint(app); + app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) }; app.dialog = Some(Dialog::LayerStyles); Ok(()) } @@ -48,10 +67,12 @@ pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result mark { - if !app.session.undo() { - break; - } + if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() + && !ok + { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_state(); + if changed { + app.session.bump(); } } + app.session.history.merge_key = None; app.dialog = None; } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 959d9c60..d59ba5d9 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -189,6 +189,25 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) { } } +pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { + fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { + let mut kids = app.session.project.children(folder); + sort_items(&mut kids, &app.ui.project_sort, app.ui.project_sort_desc); + for it in kids { + if !q.is_empty() && !it.is_folder() && !it.name.to_lowercase().contains(q) { + continue; + } + out.push((it.id, depth)); + if it.is_folder() && (app.ui.project_open_folders.contains(&it.id.0) || !q.is_empty()) { + walk(app, Some(it.id), depth + 1, q, out); + } + } + } + let mut rows = vec![]; + walk(app, None, 0, &app.ui.project_search.to_lowercase(), &mut rows); + rows +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); @@ -330,22 +349,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow); } } - let query = app.ui.project_search.to_lowercase(); - let mut rows: Vec<(ItemId, usize)> = vec![]; - fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { - let mut kids = app.session.project.children(folder); - sort_items(&mut kids, &app.ui.project_sort, app.ui.project_sort_desc); - for it in kids { - if !q.is_empty() && !it.is_folder() && !it.name.to_lowercase().contains(q) { - continue; - } - out.push((it.id, depth)); - if it.is_folder() && (app.ui.project_open_folders.contains(&it.id.0) || !q.is_empty()) { - walk(app, Some(it.id), depth + 1, q, out); - } - } - } - walk(app, None, 0, &query, &mut rows); + let rows = visible_rows(app); // Enter renames the selected item (Project panel focused, not typing). if app.ui.focused == crate::dock::PanelKind::Project && app.dialog.is_none() @@ -547,13 +551,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); let resp = resp.union(nresp); if resp.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - if add { - if !app.session.state.project_selection.contains(id) { + let m = ui.input(|i| i.modifiers); + let anchor_key = egui::Id::new("project-selection-anchor"); + let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied()); + if m.shift + && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a)) + { + let b = rows.iter().position(|(i, _)| i == id).unwrap_or(a); + app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect(); + } else if m.command { + if app.session.state.project_selection.contains(id) { + app.session.state.project_selection.retain(|i| i != id); + } else { app.session.state.project_selection.push(*id); } + ctx.data_mut(|d| d.insert_temp(anchor_key, *id)); } else { app.session.state.project_selection = vec![*id]; + ctx.data_mut(|d| d.insert_temp(anchor_key, *id)); } } // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and @@ -576,6 +591,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0)); } resp.context_menu(|ui| { + if ui.button("Delete").clicked() { + let items: Vec = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }; + actions.push(("project.delete".into(), json!({"items": items}))); + ui.close(); + } if matches!(it.kind, ItemKind::Comp(_)) && ui.button("Open Composition").clicked() { actions.push(("comp.open".into(), json!({"comp": id.0}))); ui.close(); diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index a5c190f7..f2a84231 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -406,6 +406,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rendering = app.session.is_rendering(); let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); let mut actions: Vec<(&'static str, Value)> = Vec::new(); + app.auto.add("renderQueue.drop", rect, "Drop compositions to queue"); + if !rendering + && let Some(crate::panels::DragPayload::Item(id)) = egui::DragAndDrop::payload::(ui.ctx()).as_deref() + && app.session.project.comp(effectcraft_engine::project::ItemId(*id)).is_some() + && ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| rect.contains(pos)) + { + let ids: Vec = if app.session.state.project_selection.contains(&effectcraft_engine::project::ItemId(*id)) { + app.session.state.project_selection.iter().filter(|i| app.session.project.comp(**i).is_some()).map(|i| i.0).collect() + } else { + vec![*id] + }; + if ui.input(|i| i.pointer.any_released()) { + for comp in ids { + actions.push(("renderQueue.add", json!({"comp": comp}))); + } + egui::DragAndDrop::clear_payload(ui.ctx()); + } else { + p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + } let mut forms: Vec<(RenderQueueItem, String)> = Vec::new(); let state_id = egui::Id::new("rq-ui"); let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default()); @@ -723,6 +743,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && editing.is_none() && !rendering && let Some(id) = selected + && app.dialog.is_none() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 427bc035..86cbd8fd 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -428,32 +428,34 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut acts: Vec = vec![]; let (mut ok, mut cancel) = (false, false); super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| { - let body_h = 470.0; + let body_h = (ctx.content_rect().height() - 170.0).clamp(120.0, 470.0); ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| { ui.set_height(body_h); // Page list (left, like After Effects). ui.vertical(|ui| { ui.set_width(180.0); - for p in &all { - let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click()); - let sel = page == p.id; - if sel { - ui.painter().rect_filled(r, 3.0, t.accent); - } else if resp.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); + egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + for p in &all { + let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click()); + let sel = page == p.id; + if sel { + ui.painter().rect_filled(r, 3.0, t.accent); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text( + r.left_center() + vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + p.title, + Tokens::ui(12.5), + if sel { Color32::WHITE } else { t.text }, + ); + app.auto.add(&format!("settings.page.{}", p.id), r, p.title); + if resp.clicked() { + page = p.id.to_string(); + } } - ui.painter().text( - r.left_center() + vec2(10.0, 0.0), - egui::Align2::LEFT_CENTER, - p.title, - Tokens::ui(12.5), - if sel { Color32::WHITE } else { t.text }, - ); - app.auto.add(&format!("settings.page.{}", p.id), r, p.title); - if resp.clicked() { - page = p.id.to_string(); - } - } + }); }); ui.separator(); ui.vertical(|ui| { diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index 8baa76ed..2dbc1786 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -37,7 +37,7 @@ fn thumb(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, budget: &mut b } *budget = false; let tex = templates::thumbnail(&app.session, id).ok().and_then(|t| templates::decode_thumb(&t)).map(|rgb| { - let px = rgb.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect(); + let px = rgb.as_chunks::<3>().0.iter().map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect(); ctx.load_texture(format!("template-thumb-{id}"), egui::ColorImage::new([THUMB_W, THUMB_H], px), egui::TextureOptions::LINEAR) }); app.template_thumbs.insert(id.to_string(), tex.clone()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a9d81173..281d3e9f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1205,6 +1205,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Registered before the rows so keys, bars and editors on top of it get the pointer first. let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); + let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y)); + let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag()); + app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers"); let mut hit_rows: Vec<(Rect, Row)> = vec![]; for (ri, row) in rows.iter().enumerate() { // Outline widgets never spill into the time graph (narrow timelines). @@ -1336,7 +1339,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); if row_resp.clicked() && !locked { let m = ui.input(|i| i.modifiers); - actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command, "add": m.shift}))); + let anchor_key = egui::Id::new(("timeline-selection-anchor", cid.0)); + let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| selected.first().copied()); + let range = if m.shift { + anchor.and_then(|a| comp.layers.iter().position(|l| l.id == a)).map(|a| { + let b = comp.layers.iter().position(|l| l.id == layer.id).unwrap_or(a); + comp.layers + .get(a.min(b)..=a.max(b)) + .unwrap_or_default() + .iter() + .filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)) + .map(|l| l.id.0) + .collect::>() + }) + } else { + None + }; + if let Some(layers) = range { + actions.push(("layer.select".into(), json!({"layers": layers}))); + } else { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command}))); + ctx.data_mut(|d| d.insert_temp(anchor_key, layer.id)); + } } if row_resp.double_clicked() { match &layer.source { @@ -2015,6 +2039,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.set_clip_rect(full_clip); + if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { + actions.push(("layer.select".into(), json!({"layers": []}))); + } + if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), outline_empty.interact_pointer_pos()) + && outline_empty.dragged() + { + let box_rect = Rect::from_two_pos(start, end).intersect(outline_rect); + lp.rect_filled(box_rect, 0.0, t.accent.gamma_multiply(0.12)); + lp.rect_stroke(box_rect, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-layer-box"), box_rect)); + } + if outline_empty.drag_stopped() + && let Some(box_rect) = ctx.data(|d| d.get_temp::(egui::Id::new("tl-layer-box"))) + { + ctx.data_mut(|d| d.remove::(egui::Id::new("tl-layer-box"))); + let layers: Vec = hit_rows + .iter() + .filter(|(r, row)| matches!(row.kind, RowKind::Layer) && r.intersects(box_rect)) + .filter(|(_, row)| comp.layer(row.layer).is_some_and(|l| !l.switches.locked)) + .map(|(_, row)| row.layer.0) + .collect(); + actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)}))); + } // Pick whips: a line follows the pointer; releasing over a row links to it. if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) { let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start); @@ -2287,6 +2334,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { UiAct::EndMerge => app.session.history.merge_key = None, } } + if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { + app.ui.viewer.property_interacting = true; + ctx.request_repaint(); + } for (id, params) in actions { if id == "__endMerge" { app.session.history.merge_key = None; @@ -2540,63 +2591,66 @@ fn zoom_at(app: &mut EffectcraftApp, comp: &Comp, tm: TMap, k: f64, at_x: f32) { } fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { - resp.context_menu(|ui| { - let id = layer.id.0; - let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { - if ui.button(label).clicked() { - actions.push(("layer.select".into(), json!({"layers": [id]}))); - actions.push((cmd.into(), params)); - ui.close(); - } - }; - ui.menu_button("New", |ui| { - item(ui, "Text", "layer.newText", json!({"text": "Text"})); - item(ui, "Solid…", "app.solidSettings", json!({})); - item(ui, "Light…", "layer.newLight", json!({})); - item(ui, "Camera…", "layer.newCamera", json!({})); - item(ui, "Null Object", "layer.newNull", json!({})); - item(ui, "Shape Layer", "layer.newShape", json!({"kind": "none"})); - item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); - }); - item(ui, "Layer Settings…", "layer.settings", json!({})); - ui.separator(); - ui.menu_button("Masks", |ui| { - item(ui, "New Mask", "layer.addMask", json!({"layer": id})); - }); - ui.menu_button("Transform", |ui| { - item(ui, "Reset", "layer.transform", json!({"layers": [id], "op": "reset"})); - item(ui, "Center In View", "layer.transform", json!({"layers": [id], "op": "center"})); - item(ui, "Fit to Comp", "layer.transform", json!({"layers": [id], "op": "fit"})); - item(ui, "Flip Horizontal", "layer.transform", json!({"layers": [id], "op": "flipH"})); - item(ui, "Flip Vertical", "layer.transform", json!({"layers": [id], "op": "flipV"})); - }); - ui.menu_button("Time", |ui| { - item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": [id]})); - item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": [id]})); - item(ui, "Time Stretch…", "layer.timeStretch", json!({})); - item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": [id]})); - item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": [id]})); - }); - ui.menu_button("Blending Mode", |ui| { - for m in BlendMode::ALL { - item(ui, m.label(), "layer.setBlendMode", json!({"layers": [id], "mode": m.label()})); - if m.ends_group() { - ui.separator(); - } - } - }); - ui.menu_button("Arrange", |ui| { - item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": [id], "to": "front"})); - item(ui, "Bring Layer Forward", "layer.arrange", json!({"layers": [id], "to": "forward"})); - item(ui, "Send Layer Backward", "layer.arrange", json!({"layers": [id], "to": "backward"})); - item(ui, "Send Layer to Back", "layer.arrange", json!({"layers": [id], "to": "back"})); - }); - ui.separator(); - item(ui, "Pre-compose…", "layer.precompose", json!({"layers": [id]})); - item(ui, "Duplicate", "edit.duplicate", json!({"layers": [id]})); - item(ui, "Split Layer", "edit.splitLayer", json!({"layers": [id]})); - item(ui, "Delete", "edit.clear", json!({"layers": [id]})); + resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); +} + +/// Shared layer actions for Timeline rows and Composition context menus. +pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(String, serde_json::Value)>) { + let Some(&id) = layers.first() else { return }; + let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { + if ui.button(label).clicked() { + actions.push(("layer.select".into(), json!({"layers": layers}))); + actions.push((cmd.into(), params)); + ui.close(); + } + }; + ui.menu_button("New", |ui| { + item(ui, "Text", "layer.newText", json!({"text": "Text"})); + item(ui, "Solid…", "app.solidSettings", json!({})); + item(ui, "Light…", "layer.newLight", json!({})); + item(ui, "Camera…", "layer.newCamera", json!({})); + item(ui, "Null Object", "layer.newNull", json!({})); + item(ui, "Shape Layer", "layer.newShape", json!({"kind": "none"})); + item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); + item(ui, "Layer Settings…", "layer.settings", json!({})); + ui.separator(); + ui.menu_button("Masks", |ui| { + item(ui, "New Mask", "layer.addMask", json!({"layer": id})); + }); + ui.menu_button("Transform", |ui| { + item(ui, "Reset", "layer.transform", json!({"layers": layers, "op": "reset"})); + item(ui, "Center In View", "layer.transform", json!({"layers": layers, "op": "center"})); + item(ui, "Fit to Comp", "layer.transform", json!({"layers": layers, "op": "fit"})); + item(ui, "Flip Horizontal", "layer.transform", json!({"layers": layers, "op": "flipH"})); + item(ui, "Flip Vertical", "layer.transform", json!({"layers": layers, "op": "flipV"})); + }); + ui.menu_button("Time", |ui| { + item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": layers})); + item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": layers})); + item(ui, "Time Stretch…", "layer.timeStretch", json!({})); + item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": layers})); + item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); + }); + ui.menu_button("Blending Mode", |ui| { + for m in BlendMode::ALL { + item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); + if m.ends_group() { + ui.separator(); + } + } + }); + ui.menu_button("Arrange", |ui| { + item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"})); + item(ui, "Bring Layer Forward", "layer.arrange", json!({"layers": layers, "to": "forward"})); + item(ui, "Send Layer Backward", "layer.arrange", json!({"layers": layers, "to": "backward"})); + item(ui, "Send Layer to Back", "layer.arrange", json!({"layers": layers, "to": "back"})); + }); + ui.separator(); + item(ui, "Pre-compose…", "layer.precompose", json!({"layers": layers})); + item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); + item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); + item(ui, "Delete", "edit.clear", json!({"layers": layers})); } fn fmt_num(v: f64, d: usize) -> String { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 0c80fb20..ddfb0a72 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -292,6 +292,29 @@ fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { true } +/// Separating-axis test: a marquee may cross a layer without containing any corner. +fn quad_intersects_box(q: &[[f64; 2]; 4], a: [f64; 2], b: [f64; 2]) -> bool { + if q.iter().flatten().chain(a.iter()).chain(b.iter()).any(|v| !v.is_finite()) { + return false; + } + let lo = [a[0].min(b[0]), a[1].min(b[1])]; + let hi = [a[0].max(b[0]), a[1].max(b[1])]; + let rect = [lo, [hi[0], lo[1]], hi, [lo[0], hi[1]]]; + let axes = [[1.0, 0.0], [0.0, 1.0]].into_iter().chain((0..4).map(|i| { + let j = (i + 1) % 4; + [q[j][1] - q[i][1], q[i][0] - q[j][0]] + })); + for axis in axes { + let project = |p: &[f64; 2]| p[0] * axis[0] + p[1] * axis[1]; + let (qmin, qmax) = q.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + let (rmin, rmax) = rect.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + if qmax < rmin || rmax < qmin { + return false; + } + } + true +} + /// Parent-space linear inverse (to convert a comp-space drag delta into a position delta). fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 { match layer.parent.and_then(|p| ctx.comp.layer(p)) { @@ -860,6 +883,47 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Interaction. let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag()); + let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0)); + if resp.secondary_clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + // Picking an already selected layer preserves the group; an unselected hit + // becomes the target, using the same visible/unlocked hit test as left-click. + pick(app, &ectx, map.to_comp(pos), false); + let point = map.to_comp(pos); + let hits: Vec<(u64, String)> = selectable_layers(&comp, time) + .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| point_in_quad(point, &q))) + .map(|l| (l.id.0, l.name.clone())) + .collect(); + ui.data_mut(|d| d.insert_temp(menu_hits_id, hits)); + } + let context_layers: Vec = + app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); + let mut context_actions = Vec::new(); + resp.context_menu(|ui| { + let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); + if !hits.is_empty() { + ui.menu_button("Select", |ui| { + for (id, name) in &hits { + if ui.button(name).clicked() { + context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); + ui.close(); + } + } + }); + ui.separator(); + } + if context_layers.is_empty() { + ui.label("No unlocked layer selected"); + } else { + super::timeline::layer_menu(ui, &context_layers, &mut context_actions); + } + }); + for (id, params) in context_actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } let gid = egui::Id::new("viewer-gesture"); if let Some(hp) = resp.hover_pos() { app.pointer_comp = Some({ @@ -1106,7 +1170,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { // With path vertices on screen the marquee selects vertices (layers stay // selected); otherwise it starts from a clean selection. - if !vertex_hits.iter().any(|h| h.tangent.is_none()) { + if !mods.shift && !vertex_hits.iter().any(|h| h.tangent.is_none()) { let _ = app.session.execute("edit.deselectAll", json!({})); } Some(Gesture::Marquee { start: press }) @@ -1353,13 +1417,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::Marquee { start } => { let a = map.to_comp(start); let b = map.to_comp(pos); - let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0])); - let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1])); let hits: Vec = selectable_layers(&comp, time) - .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| q.iter().any(|p| p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1))) + .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b))) .map(|l| l.id.0) .collect(); - let _ = app.session.execute("layer.select", json!({"layers": hits})); + let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift})); } _ => {} } diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 37f7d082..a9ce2eb5 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -320,7 +320,7 @@ pub fn hook( } // ---- keyboard - if app.session.state.text_edit.is_some() { + if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() { ui.memory_mut(|m| { m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); }); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 534e501d..73407a93 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -711,7 +711,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, app.auto.add("viewer.timecode", tr, "Current time"); // Right: render time. let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); - p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); + if tr.max.x + 50.0 < bar.max.x { + p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d26d344b..be8ef385 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -303,6 +303,8 @@ pub struct ViewerState { /// The pointer is dragging in the viewer this frame (Adaptive Resolution). #[serde(skip)] pub interacting: bool, + #[serde(skip)] + pub property_interacting: bool, /// Extended Viewer: the comp-space region the viewer renders this frame (comp frame plus /// the visible pasteboard), set while a 3D view shows past the frame. #[serde(skip)] @@ -334,6 +336,7 @@ impl Default for ViewerState { ruler_origin: [0.0, 0.0], roi_draw: false, interacting: false, + property_interacting: false, extended: None, pasteboard: None, custom_pasteboard: [0x80, 0x80, 0x80], diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 288a04b5..1d1720ff 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -114,7 +114,7 @@ impl Tokens { accent_hover: Color32::from_rgb(0x4a, 0xa0, 0xf5), text: Color32::from_rgb(0xc8, 0xc8, 0xc8), text_dim: Color32::from_rgb(0x9a, 0x9a, 0x9a), - text_faint: Color32::from_rgb(0x66, 0x66, 0x66), + text_faint: Color32::from_rgb(0x8c, 0x8c, 0x8c), icon: Color32::from_rgb(0xb4, 0xb4, 0xb4), icon_active: Color32::from_rgb(0xf0, 0xf0, 0xf0), hover: Color32::from_rgb(0x30, 0x30, 0x30), @@ -170,7 +170,9 @@ impl Tokens { tab_text_active: Color32::from_rgb(0x14, 0x14, 0x14), text: Color32::from_rgb(0x22, 0x22, 0x22), text_dim: Color32::from_rgb(0x5a, 0x5a, 0x5a), - text_faint: Color32::from_rgb(0x8c, 0x8c, 0x8c), + text_faint: Color32::from_rgb(0x68, 0x68, 0x68), + hot_text: Color32::from_rgb(0x00, 0x5a, 0x9c), + timecode: Color32::from_rgb(0x00, 0x5a, 0x9c), icon: Color32::from_rgb(0x3c, 0x3c, 0x3c), icon_active: Color32::from_rgb(0x10, 0x10, 0x10), hover: Color32::from_rgb(0xd0, 0xd0, 0xd0), @@ -307,11 +309,11 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.inactive.bg_fill = t.field_bg; - v.widgets.inactive.weak_bg_fill = Color32::from_rgb(0x2e, 0x2e, 0x2e); + v.widgets.inactive.weak_bg_fill = t.field_bg; v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.hovered.bg_fill = t.hover; - v.widgets.hovered.weak_bg_fill = Color32::from_rgb(0x3a, 0x3a, 0x3a); + v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.hovered.fg_stroke = Stroke::new(1.0, t.tab_text_active); v.widgets.active.bg_fill = t.pressed; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e483f12b..61cb9c5e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -209,7 +209,7 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token } else if resp.hovered() { t.pressed } else { - Color32::from_rgb(0x33, 0x33, 0x33) + t.hover }; ui.painter().rect_filled(rect, rect.height() / 2.0, bg); ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), if primary { Color32::WHITE } else { t.text }); diff --git a/crates/ui-egui/tests/ui_camera_tracker.rs b/crates/ui-egui/tests/ui_camera_tracker.rs index 963ce3b6..9e0bede6 100644 --- a/crates/ui-egui/tests/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/ui_camera_tracker.rs @@ -70,6 +70,23 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, at: egui::Pos2, modifiers: egui::M h.step(); } +#[test] +fn deleting_viewer_track_points_preserves_the_effect_and_layer() { + let (a, plate, uid) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + let point = rect(&h, "viewer.cameraTracker.point.7").center(); + click(&mut h, point, Default::default()); + h.state_mut().ui.focused = PanelKind::Composition; + h.input_mut().events.push(egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(3); + let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(plate)).unwrap(); + let effect = layer.props.find_group(uid).expect("tracker effect survived"); + let params = effectcraft_engine::camera_track::static_params(effect); + assert!(ct::deleted(¶ms).contains(&7)); +} + #[test] fn viewer_points_selection_and_effect_controls() { let (a, plate, uid) = app(); diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index a6966f83..4aca21a5 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -25,6 +25,7 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { eprintln!("no GPU adapter: skipping"); return; }; + eprintln!("headless compositor adapter: {}", effectcraft_engine::render::Accelerator::name(&probe)); // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. let gl = effectcraft_engine::render::Accelerator::name(&probe).ends_with("(Gl)"); drop(probe); @@ -38,6 +39,7 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { return; } assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); + eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter()); let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame"); let gpu_shot = h.render().expect("render"); // Mercury Software Only: the same frame from the CPU. diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/ui_project_delete.rs index a343740d..2891b8cd 100644 --- a/crates/ui-egui/tests/ui_project_delete.rs +++ b/crates/ui-egui/tests/ui_project_delete.rs @@ -76,7 +76,7 @@ fn delete_and_backspace_remove_project_items_and_undo() { fn project_multi_selection_deletes_together_and_undoes_once() { let (mut h, items, keep) = harness(); for (i, item) in items.iter().enumerate() { - click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::SHIFT }); + click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::COMMAND }); } assert_eq!(h.state().session.state.project_selection, items); let project = h.state().session.project.clone(); diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 86d966db..ceed810e 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -71,6 +71,24 @@ fn type_tool_skips_hidden_and_unsoloed_text_layers() { } } +#[test] +fn modal_blocks_background_text_events() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.run_steps(3); + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let p = rect(&h, "viewer.comp").center(); + click(&mut h, p, Modifiers::NONE); + type_text(&mut h, "Preserve"); + let before = edited(&h).1; + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); + h.run_steps(3); + h.input_mut().events.push(Event::Text("changed".into())); + h.input_mut().events.push(Event::Paste("pasted".into())); + key(&mut h, Key::Backspace, Modifiers::NONE); + assert_eq!(edited(&h).1, before); +} + #[test] fn type_tool_click_type_edit_and_commit() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app()); diff --git a/crates/ui-egui/tests/ui_visual_review.rs b/crates/ui-egui/tests/ui_visual_review.rs new file mode 100644 index 00000000..70cc5ed8 --- /dev/null +++ b/crates/ui-egui/tests/ui_visual_review.rs @@ -0,0 +1,50 @@ +//! Opt-in visual review of every built-in panel, workspaces and common dialogs. +//! Original fixture; captures are review outputs, not shipped assets. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; +use egui_kittest::Harness; +use serde_json::json; + +#[test] +#[ignore] +fn all_ui_review_captures() { + let out = std::path::PathBuf::from(std::env::var("UI_REVIEW_DIR").expect("set UI_REVIEW_DIR")); + std::fs::create_dir_all(&out).unwrap(); + for (label, size) in [("wide", egui::vec2(1600.0, 1000.0)), ("compact", egui::vec2(1024.0, 768.0))] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"UI Review","width":640,"height":360,"duration":4})).unwrap(); + s.execute("layer.newText", json!({"text":"EffectCraft","name":"Title"})).unwrap(); + s.execute("layer.newSolid", json!({"name":"Plate","color":"#406080","width":160,"height":120})).unwrap(); + s.execute("renderQueue.add", json!({"format":"png","output":"review-[#####].png"})).unwrap(); + std::sync::Arc::make_mut(&mut s.project).settings.gpu_acceleration = false; + let mut h = Harness::builder().with_size(size).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + h.render().unwrap().save(out.join(format!("{label}-workspace.png"))).unwrap(); + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Light); + h.run_steps(4); + h.render().unwrap().save(out.join(format!("{label}-workspace-light.png"))).unwrap(); + h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Dark); + for p in PanelKind::ALL { + h.state_mut().show_panel(p); + h.state_mut().ui.maximized = Some(p); + h.run_steps(4); + assert!(h.state().auto.find(&format!("panel.{}", p.id())).is_some(), "{} opens", p.title()); + h.render().unwrap().save(out.join(format!("{label}-{}.png", p.id()))).unwrap(); + } + h.state_mut().ui.maximized = None; + for (name, dialog) in [ + ("about", Dialog::About), + ("new-comp", Dialog::NewComp), + ("preferences", Dialog::Settings), + ("shortcuts", Dialog::Shortcuts), + ("palette", Dialog::CommandPalette), + ] { + h.state_mut().dialog = Some(dialog); + h.run_steps(4); + h.render().unwrap().save(out.join(format!("{label}-dialog-{name}.png"))).unwrap(); + h.state_mut().dialog = None; + h.run_steps(2); + } + } +} diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs new file mode 100644 index 00000000..4e849dca --- /dev/null +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -0,0 +1,374 @@ +//! User-reported selection, Project deletion, and render-queue drop regressions. +use effectcraft_engine::{Session, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Study", "width":640,"height":360,"duration":4})).unwrap(); + for n in 0..4 { + s.execute("layer.newSolid", json!({"name":format!("Tile {n}"),"width":80,"height":80,"color":"#406080"})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} +fn pointer(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, down: bool, m: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(m)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); + h.step(); +} +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, m: Modifiers) { + pointer(h, p, true, m); + pointer(h, p, false, m); + h.run_steps(2); +} +fn drag(h: &mut Harness<'_, EffectcraftApp>, a: Pos2, b: Pos2, m: Modifiers) { + pointer(h, a, true, m); + for n in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (n as f32 / 8.0))); + h.step(); + } + pointer(h, b, false, m); + h.run_steps(2); +} +#[test] +fn timeline_shift_click_selects_range_and_ctrl_toggles() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + let point = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) + }; + let p = point(&h, ids[0]); + click(&mut h, p, Modifiers::NONE); + let p = point(&h, ids[2]); + click(&mut h, p, Modifiers::SHIFT); + assert_eq!(h.state().session.state.selected_layers, ids[..3]); + let p = point(&h, ids[1]); + click(&mut h, p, Modifiers::COMMAND); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[2]]); +} +#[test] +fn timeline_empty_outline_marquee_selects_layers() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + let area = rect(&h, "timeline.layerMarquee"); + let top = rect(&h, &format!("timeline.layer.{}.row", ids[0].0)); + let last = rect(&h, &format!("timeline.layer.{}.row", ids[3].0)); + drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE); + assert_eq!(h.state().session.state.selected_layers, ids); +} +#[test] +fn project_clear_deletes_items_and_undo_restores_them() { + let mut h = harness(); + let id = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.state.project_selection = vec![id]; + h.state_mut().ui.focused = PanelKind::Project; + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap(); + assert!(h.state().session.project.comp(id).is_none()); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.project.comp(id).is_some()); +} +#[test] +fn project_comp_drop_queues_the_dragged_comp() { + let mut h = harness(); + let id = h.state().session.active_comp_id().unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap(); + h.run_steps(3); + let a = rect(&h, &format!("project.item.{}.name", id.0)).center(); + let b = rect(&h, "renderQueue.drop").center(); + drag(&mut h, a, b, Modifiers::NONE); + assert_eq!(h.state().session.project.render_queue.len(), 1); + assert_eq!(h.state().session.project.render_queue[0].comp, id); +} + +#[test] +fn viewer_marquee_crosses_a_layer_interior_and_shift_preserves_selection() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + for (n, id) in ids.iter().enumerate() { + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); + } + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let screen = |x: f32, y: f32| comp.min + vec2(x, y) * (comp.width() / 640.0); + drag(&mut h, screen(60.0, 170.0), screen(180.0, 190.0), Modifiers::NONE); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); + drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT); + assert!(h.state().session.state.selected_layers.contains(&ids[0])); + assert!(h.state().session.state.selected_layers.contains(&ids[1])); + drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); +} + +#[test] +fn render_queue_delete_preserves_selected_timeline_layers() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); + h.state_mut().session.execute("renderQueue.add", json!({})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap(); + h.run_steps(3); + let row = rect(&h, "renderQueue.item.1.row"); + click(&mut h, pos2(row.max.x - 15.0, row.center().y), Modifiers::NONE); + assert_eq!(h.state().ui.focused, PanelKind::RenderQueue); + h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().session.project.render_queue.is_empty()); + assert!(h.state().session.active_comp().unwrap().layer(id).is_some()); + assert_eq!(h.state().session.state.selected_layers, vec![id]); +} + +#[test] +fn project_shift_selects_visible_range_and_ctrl_removes_item() { + let mut h = harness(); + let mut ids = vec![]; + for name in ["A Range", "B Range", "C Range"] { + ids.push(h.state_mut().session.execute("comp.new", json!({"name":name,"width":64,"height":64,"duration":1})).unwrap()["comp"].as_u64().unwrap()); + } + h.run_steps(3); + let p = rect(&h, &format!("project.item.{}.name", ids[0])).center(); + click(&mut h, p, Modifiers::NONE); + let p = rect(&h, &format!("project.item.{}.name", ids[2])).center(); + click(&mut h, p, Modifiers::SHIFT); + assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::>(), ids); + let p = rect(&h, &format!("project.item.{}.name", ids[1])).center(); + click(&mut h, p, Modifiers::COMMAND); + assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::>(), vec![ids[0], ids[2]]); +} + +#[test] +fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); + h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap(); + h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap(); + h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap(); + h.run_steps(3); + let prop = h.state().session.active_comp().unwrap().layer(id).unwrap().effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid; + let a = rect(&h, &format!("effectControls.prop.{prop}.value")).center(); + pointer(&mut h, a, true, Modifiers::NONE); + h.input_mut().events.push(Event::PointerMoved(a + vec2(30.0, 0.0))); + h.run_steps(2); + assert!(h.state().ui.viewer.property_interacting); + assert!(h.state().viewer_scale(1.0, 1.0) < 1.0); + h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); + pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE); + h.run_steps(2); + assert!(!h.state().ui.viewer.property_interacting); + h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; + assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); +} + +#[test] +fn viewer_right_click_targets_hit_and_duplicate_preserves_group() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + for (n, id) in ids.iter().enumerate() { + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); + } + h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0]})).unwrap(); + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let p = comp.min + vec2(120.0, 180.0) * (comp.width() / 640.0); + let right_click = |h: &mut Harness<'_, EffectcraftApp>| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + right_click(&mut h); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); + if let Ok(out) = std::env::var("CONTEXT_MENU_SNAPSHOT") { + let comp = h.state().session.active_comp_id().unwrap(); + let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) }; + for _ in 0..600 { + h.step(); + if h.state().frames.is_cached(&key) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().frames.is_cached(&key)); + h.run_steps(3); + h.render().expect("offscreen context menu render").save(out).unwrap(); + } + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 5); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0,ids[1].0]})).unwrap(); + h.run_steps(3); + right_click(&mut h); + assert_eq!(h.state().session.state.selected_layers.len(), 2); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); +} + +#[test] +fn viewer_context_select_reaches_an_obscured_layer() { + let mut h = harness(); + let target = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Tile 1").unwrap().id; + let p = rect(&h, "viewer.comp").center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_label("Select ⏵").click(); + h.run_steps(3); + h.get_by_label("Tile 1").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.selected_layers, vec![target]); +} + +/// Original four-tile UI fixture; no Adobe assets or reference screenshots. +#[test] +fn modal_blocks_clicks_on_underlying_timeline_layers() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0]})).unwrap(); + let row = rect(&h, &format!("timeline.layer.{}.row", ids[2].0)); + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); + h.run_steps(3); + click(&mut h, pos2(row.min.x + 210.0, row.center().y), Modifiers::NONE); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::About)); +} + +#[test] +fn layer_style_escape_rolls_back_live_preview() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); + h.run_steps(3); + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25})).unwrap(); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let styles = h.state_mut().session.execute("layer.style.list", json!({})).unwrap(); + assert!(styles["styles"].as_array().unwrap().is_empty()); +} + +#[test] +fn style_cancel_restores_checkpoint_with_bounded_history_and_reopened_scrub() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); + h.state_mut().session.execute("layer.style.dropShadow", json!({})).unwrap(); + h.state_mut().session.prefs.general.undo_levels = 1; + let ctx = h.ctx.clone(); + let open = |h: &mut Harness<'_, EffectcraftApp>| { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style":"dropShadow"})).unwrap(); + }; + open(&mut h); + let before = h.state().session.project.clone(); + let history = h.state().session.history.undo.len(); + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25,"merge":"same-style-control"})).unwrap(); + effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false); + assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before)); + assert_eq!(h.state().session.history.undo.len(), history); + open(&mut h); + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":50,"merge":"same-style-control"})).unwrap(); + effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true); + let accepted = h.state().session.project.clone(); + open(&mut h); + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":75,"merge":"same-style-control"})).unwrap(); + effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false); + assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &accepted)); +} + +#[test] +fn project_edit_commands_do_not_target_timeline_layers() { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); + h.state_mut().session.execute("project.select", json!({"items":[comp.0]})).unwrap(); + h.state_mut().ui.focused = PanelKind::Project; + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.duplicate", json!({})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 4); + assert_ne!(h.state().session.state.project_selection, vec![comp]); + assert!(effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.cut", json!({})).is_err()); + assert!(h.state().session.active_comp().unwrap().layer(id).is_some()); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.deselectAll", json!({})).unwrap(); + assert!(h.state().session.state.project_selection.is_empty()); + assert_eq!(h.state().session.state.selected_layers, vec![id]); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap(); + assert!(h.state().session.state.project_selection.contains(&comp)); + assert_eq!(h.state().session.state.selected_layers, vec![id]); +} + +#[test] +fn compact_settings_keeps_confirmation_buttons_clickable() { + let mut h = Harness::builder().with_size(vec2(1024.0, 768.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::Settings); + h.run_steps(4); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn filtered_project_select_all_does_not_delete_hidden_assets() { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let before = h.state().session.project.items.len(); + h.state_mut().ui.focused = PanelKind::Project; + h.state_mut().ui.project_search = "Study".into(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap(); + assert_eq!(h.state().session.state.project_selection, vec![comp]); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap(); + assert_eq!(h.state().session.project.items.len(), before - 1); +} + +/// Original four-tile UI fixture; no Adobe assets or reference screenshots. +#[test] +#[ignore] +fn workflow_snapshot() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + for (n, id) in ids.iter().enumerate() { + h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); + } + h.state_mut().session.execute("layer.select", json!({"layers":ids[..3].iter().map(|i|i.0).collect::>()})).unwrap(); + h.run_steps(3); + let out = std::env::var("WORKFLOW_SNAPSHOT").expect("set WORKFLOW_SNAPSHOT to the output PNG"); + let comp = h.state().session.active_comp_id().unwrap(); + let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) }; + for _ in 0..600 { + h.step(); + if h.state().frames.is_cached(&key) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().frames.is_cached(&key), "current composition rendered before capture"); + h.run_steps(3); + h.render().expect("offscreen UI render").save(out).unwrap(); +} diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs index 58143375..f438cbf1 100644 --- a/crates/vp9enc/src/lib.rs +++ b/crates/vp9enc/src/lib.rs @@ -452,6 +452,6 @@ pub fn rgba_to_yuv420(rgba: &[u8], w: u32, h: u32, full_range: bool) -> (Vec pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec, Vec, Vec) { let (w, h) = (w as usize, h as usize); let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let y: Vec = rgba.chunks_exact(4).take(w * h).map(|p| p[3]).collect(); + let y: Vec = rgba.as_chunks::<4>().0.iter().take(w * h).map(|p| p[3]).collect(); (y, vec![128; cw * ch], vec![128; cw * ch]) } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md new file mode 100644 index 00000000..682ea24e --- /dev/null +++ b/docs/fidelity/README.md @@ -0,0 +1,57 @@ +# Measured keyframe fidelity + +The [AE 2026 replacement acceptance gates](ae-2026-acceptance.md) define the broader +target across workflows, rendered output, work-project compatibility, performance and platforms. + +The first G1 comparison measures eight original scalar Rotation cases against a live After Effects +2026 session through AEsync 2.0.4: Easy Ease, overlapping influences, asymmetric ease, overshoot, +descending values, equal endpoints, 100%/100% influence and 0.1%/100% influence. Each case has +21 samples over a one-second segment. This is a scalar temporal-interpolation measurement, not an +overall animation, rendering, spatial-path or application-parity score. + +## Reproduce + +With Node.js, Rust and an installed AEsync CLI, open a scratch After Effects project and run from +the EffectCraft checkout: + +```sh +node examples/check-ae-ease.mjs /path/to/aesync/server/dist/cli.js +``` + +The external bridge must be answering. Signing into AEsync's embedded chat is not required. +The script creates a temporary comp with an original text layer, samples its Rotation property and +removes the comp on success or failure. It does not save, close or replace the current project. +Run in a scratch project: creating/removing the comp still changes the dirty flag and undo history. + +`crates/keyframe/examples/ae_ease_samples.rs` evaluates the corresponding EffectCraft keys. +`examples/ae-ease-oracle.jsx` records live property samples and actual ease settings after the setters. +`examples/check-ae-ease.mjs` checks case metadata, applied AE settings, finite values and sample times, +then compares every value with an absolute tolerance of `1e-7` degrees. A failing score exits nonzero. + +Raw host observations and EffectCraft samples stay in gitignored `plan/aftereffects/ref/ease/`. +Only the original harness and aggregate scores in `keyframe-ease.json` are tracked. No Adobe artwork, +presets, screenshots, frames or implementation code are included. + +## Finding and validation + +Measured on 5 October 2026 with After Effects `26.3x87` and AEsync 2.0.4. Before the fix, +EffectCraft normalized outgoing/incoming influence when their sum exceeded 100%. AE retains each +influence. That normalization caused maximum errors of 12.4581 degrees for 80%/80%, 8.8909 degrees +for the asymmetric case and 5.5636 degrees for the descending case. Removing normalization in both +scalar evaluation and the shared progress helper makes all eight cases pass; the largest difference +in the 168 samples is below `3e-11` degrees. + +The numeric regression test checks AE-derived behavioral facts, including the formerly failing +sample at 0.1 seconds (0.59243939036327 degrees; previously 1.4621885963370382). Existing linear, +hold, spatial, roving and eased-range tests remain green. + +Astra review also found premature Newton convergence near the flat midpoint of the 100%/100% +time curve. Its closed-form inverse and a parameter-based convergence check now have a separate +mathematical regression test at times within `1e-12` of the midpoint; that test is additional to +the 168 live AE samples. + +## Still unmeasured + +Mixed interpolation sides, non-unit durations, automatic/continuous Bezier, spatial speeds and paths, +time remapping, expressions, actual rendered motion and other effects need separate corpus cases. +The initial probe makes no claim about those features. diff --git a/docs/fidelity/ae-2026-acceptance.md b/docs/fidelity/ae-2026-acceptance.md new file mode 100644 index 00000000..042d1177 --- /dev/null +++ b/docs/fidelity/ae-2026-acceptance.md @@ -0,0 +1,48 @@ +# After Effects 2026 replacement acceptance + +Target: reproduce observable After Effects 2026 behavior and output closely enough to +replace it in professional motion-design work on Windows, macOS, and Linux. Feature +presence, internal CPU/GPU agreement, and a polished interface are separate evidence; +none establishes this target on its own. Pin each reference case to the exact AE build, +platform, fonts, color settings, source files, and project parameters. + +## Acceptance gates + +| Gate | Evidence required | Current boundary | +| --- | --- | --- | +| Work projects | Representative projects recreated or imported, edited, saved, reopened and delivered without lost state | Native AE project import and third-party AE plug-in execution remain gaps | +| Workflow | Pointer and keyboard cases for all panels, tools, shortcuts, docking, undo, dialogs and selections | Current pass covers 34 panel layouts and selected interaction regressions; every control is not verified | +| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; other cases remain open | +| Render fidelity | Original test scenes compared at 8/16/32 bpc, specified color spaces and alpha conventions; edges, impulses, gradients and temporal sequences | Most AE-rendered comparisons remain open; CPU/GPU agreement alone is insufficient | +| Media and delivery | Real footage import, seek, frame rates, audio sync, relinking, image sequences, codec/container metadata and exports checked in downstream tools | Actual work fixtures still required | +| Performance | Matched AE/EffectCraft input-to-present and export timings at 1080p/4K, warm/cold caches, memory/VRAM and long sessions | No claim of beating AE or measured blur response yet | +| Platforms | Native Windows, macOS and Linux runs with recorded GPU, driver, backend, device limits and fault cases | RTX 5090 Vulkan viewer/effect CPU comparisons pass; Direct3D FXC setup fails; other platforms not validated in this pass | +| Recovery | Save/reopen, interrupted render, invalid input, exhausted resources, lost GPU and cache failures without silent corruption | Catching compositor setup failure does not repair an OS graphics bugcheck | + +## Graphics and technology policy + +Keep one shared Rust scene/effect model and portable wgpu shader/render implementation. +Use Vulkan on Linux, validated Vulkan or Direct3D 12 on Windows, and native Metal on +macOS. Browser WebGPU is a separate deployment/acceptance target. Record the backend +actually selected; an available or compiled backend is not a passed compositor test. +Use hardware capabilities and measured results to select optimized paths, with a CPU +path that preserves output semantics. Newer APIs, SIMD, parallelism, and hardware codecs +must preserve color, precision, timing and file compatibility. Avoid making baseline +features depend on a specific GPU vendor or an optional advanced device capability. + +Use current stable dependencies after regression checks; experimental technology belongs +behind an explicit optional path. Driver/compiler combinations and shared-device versus +headless initialization need the same diagnostic backend selection policy. + +## Reference process + +Use the official [AE 2026 user guide](https://helpx.adobe.com/after-effects/desktop.html), +public scripting documentation, legally available publisher samples, and original scratch +projects observed through AESync. Do not copy Adobe implementation code or redistribute +Adobe lesson artwork. Record documented expectations separately from live observations. +Maintain per-case pass/fail/untested results, tolerances and repeatable commands; do not +replace them with a global parity percentage. + +Next priorities: qualify the verified Windows Vulkan path on larger workloads; measure +Gaussian Blur interaction and output against AE; expand animation/render oracles; exercise +representative work projects and required plug-ins; then close each platform acceptance gate. diff --git a/docs/fidelity/keyframe-ease.json b/docs/fidelity/keyframe-ease.json new file mode 100644 index 00000000..b37cb2cf --- /dev/null +++ b/docs/fidelity/keyframe-ease.json @@ -0,0 +1,57 @@ +{ + "generatedAt": "2026-10-06T01:17:41.470Z", + "aeVersion": "26.3x87", + "property": "Rotation (scalar)", + "tolerance": 1e-7, + "cases": [ + { + "name": "easy", + "samples": 21, + "maxAbsoluteError": 2.842170943040401e-14, + "pass": true + }, + { + "name": "high_influence", + "samples": 21, + "maxAbsoluteError": 8.965272968453064e-12, + "pass": true + }, + { + "name": "asymmetric", + "samples": 21, + "maxAbsoluteError": 1.3642420526593924e-12, + "pass": true + }, + { + "name": "overshoot", + "samples": 21, + "maxAbsoluteError": 2.9473312679328956e-11, + "pass": true + }, + { + "name": "descending", + "samples": 21, + "maxAbsoluteError": 2.5774937739697634e-12, + "pass": true + }, + { + "name": "equal_endpoints", + "samples": 21, + "maxAbsoluteError": 4.220623850414995e-12, + "pass": true + }, + { + "name": "maximum_influence", + "samples": 21, + "maxAbsoluteError": 5.684341886080802e-14, + "pass": true + }, + { + "name": "minimum_maximum", + "samples": 21, + "maxAbsoluteError": 2.4030555323406588e-11, + "pass": true + } + ], + "pass": true +} diff --git a/docs/fidelity/workflow-checks.md b/docs/fidelity/workflow-checks.md new file mode 100644 index 00000000..4bea37ee --- /dev/null +++ b/docs/fidelity/workflow-checks.md @@ -0,0 +1,94 @@ +# Selection and preview workflow checks + +These checks use original compositions and public interaction behavior. No Adobe assets or +implementation code are included. Adobe's [selection documentation](https://helpx.adobe.com/after-effects/desktop/work-with-layers/select-and-arrange-layers/selecting-arranging-layers.html) +describes Timeline range selection and Composition Shift-click/Shift-marquee selection. + +`crates/ui-egui/tests/ui_workflow_selection.rs` exercises actual pointer and keyboard events: + +- Timeline Shift-click selects the visible range, skipping locked layers; Ctrl/Command-click toggles a layer. +- A drag beginning in the empty Timeline outline selects the intersected layer rows without reordering them. +- Project Shift-click selects the displayed range and Ctrl/Command-click toggles an item. +- Project Clear uses the undoable project-item deletion command. +- Dragging a composition from Project into Render Queue queues that composition. +- Render Queue Delete removes its selected queue item without deleting selected Timeline layers. +- Composition marquee detects intersections through a layer's interior, including Shift toggle selection. +- Gaussian Blur parameter scrubbing activates Adaptive Resolution; release restores settled resolution. + An explicitly selected Full resolution is preserved. + +The existing `ui_tabs` tests exercise panel docking, tab rearrangement and splitter resizing. +`ui_viewer`, `ui_effect_controls`, and `ui_ram_preview` check neighboring interactions and cached-frame behavior. + +## Preview scheduling + +CPU frames now enter the RAM cache and request repaint before optional RGBA conversion and +disk-cache compression. Persistence still uses the same content key and runs on the worker; +disk hits are not rewritten. The render-duration display ends at publication and excludes this +post-publication persistence work. It still omits queue waiting and is not interaction latency. + +The native preview scheduler now uses the actual render-pool capacity instead of the machine's +logical CPU count. Normal paused prefetch schedules at most two frames and leaves one worker +available where the pool permits it. A two-worker browser pool schedules at most one background +frame; a one-worker pool does not do normal paused prefetch. Explicit cache-before-playback and +Cache Frames When Idle retain their separate policies. + +Dragging render-affecting properties in Effect Controls or Timeline now participates in the +existing adaptive-preview policy. This reduces the requested pixel count while scrubbing when +Auto resolution and Adaptive Resolution are selected. It does not change the blur kernel. + +The checks establish interaction and resolution behavior, not a measured input-to-frame latency +improvement or complete visual Gaussian Blur parity. Remaining measurements include cold/warm +1080p and 4K latency, stale-frame work, CPU/GPU selection, blur impulse profiles, and edge behavior. + +## Live reference limits + +AESync 2.0.4 answered from AE 26.3x87. Original four-tile compositions were used for desktop +interaction study, and extended Timeline selection was verified by querying selected layers +after a keyboard gesture. Automated native drags did not produce reliable marquee observations; +those results are not claimed as a pixel-perfect AE oracle. Shape/path selection and modifier +drag cadence still need controlled live comparison. + +- Composition right-click selects an unselected hit layer, preserves an already selected group, + and exposes the shared Timeline layer actions. The pointer-driven regression test invokes + Duplicate from the actual popup and verifies both single-layer and group behavior. + +## Wider UI review + +The opt-in `ui_visual_review` suite captures all 34 built-in panels at 1600 × 1000 and +1024 × 768, plus common dialogs and Light theme workspaces. Captures use an original fixture +and do not establish every panel control's behavior or live input-to-frame performance. + +Additional regression checks cover modal pointer and text isolation, Escape rollback of live +Layer Style previews, cancellation with bounded undo history, Project edit command routing, +filtered Project Select All, and deleting camera track points without deleting the layer/effect. +Layer Style Cancel restores a captured project/history checkpoint rather than replaying Undo. +Filtered Project Select All omits structural folders so deleting search results cannot delete +unrelated descendants. Its exact AE parity remains to be checked live. + +The Composition context menu's Select submenu lists visible, unlocked layers under its original +click position, allowing selection of an obscured layer. Reference: +[Adobe layer selection manual](https://helpx.adobe.com/after-effects/desktop/work-with-layers/select-and-arrange-layers/selecting-arranging-layers.html). +Workspace behavior reference: +[Adobe workspace guide](https://www.adobe.com/learn/after-effects/web/customize-workspaces). +The broader reference is the [official AE user guide](https://helpx.adobe.com/after-effects/desktop.html). +The [publisher's 2026 Classroom in a Book sample](https://ptgmedia.pearsoncmg.com/images/9780135561430/samplepages/9780135561430_Sample.pdf) +was also reviewed. Its Roto Brush lesson supplies future behavior cases for foreground/background +strokes, alpha display modes, frame propagation, and freezing. Those cases are reference backlog, +not verified EffectCraft parity; book artwork and lesson files are not copied into this repository. + +Theme fixes use matched token backgrounds/text for fields, secondary buttons, notifications, +and About links. Compact viewer footers omit render duration when it would collide with timecode. +Further design work includes full toolbar overflow, tighter grouping of controls in wide panels, +and additional small-window/dynamic panel fixtures. + +GPU compositor initialization now catches backend setup panics and retains CPU compositing. +This covers a reproduced Direct3D FXC shader compiler failure; it does not repair an OS driver +failure or establish the cause of the reported Windows bugcheck. The GPU parity test is separately +reported when this device cannot create its compute pipeline, never counted as a passed test. +Native GPU threshold regressions now compare prepared quantized buffers directly with the CPU: +quantization uses the CPU-rounded reciprocal and matching half-up arithmetic; channel Arithmetic +refines unpremultiplication, and median division refines the residual twice before strict Dust & +Scratches thresholds. These checks retain the existing image tolerances. Power Pin admits a small +f32 inverse-solve error at exact quad edges and clamps accepted coordinates to the source domain. +The original half-scale right-edge fixture and neighboring quantization-boundary samples reproduce +the failures. Full backend qualification and performance measurement remain separate acceptance work. \ No newline at end of file diff --git a/docs/gaps.md b/docs/gaps.md index ac97446a..8b9964d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -31,7 +31,7 @@ of them have been reconciled. | Dimension | Estimate | Evidence | |---|---|---| | Breadth of features | ≈ 95%+ | All 306 effects, the After Effects menus and panels, Classic and Advanced 3D, tracking, expressions, scripting, render queue (parity.md) | -| Behaves like After Effects | unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. No test compares our renders or property values with After Effects itself; the tests check what the agents believed After Effects does | +| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured | | Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | | Stability | improving | 23 never-crash PRs landed on 4 October; [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | | Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. Most checking happens on macOS. No localisation, no accessibility work | diff --git a/examples/ae-ease-oracle.jsx b/examples/ae-ease-oracle.jsx new file mode 100644 index 00000000..153af362 --- /dev/null +++ b/examples/ae-ease-oracle.jsx @@ -0,0 +1,44 @@ +// Original behavior probe. Run this function body through AEsync's `run -f` command. +// Creates only a temporary text layer and removes its composition before returning. +// No Adobe assets, application internals, presets or implementation code are read. +var comp = null, layer, p, cases, results = [], times = [], i, j, c, values, output; +cases = [ + ["easy", 0, 100, 0, 100 / 3, 0, 100 / 3], + ["high_influence", 0, 100, 0, 80, 0, 80], + ["asymmetric", 0, 100, 20, 80, 80, 60], + ["overshoot", 0, 100, 400, 40, -100, 40], + ["descending", 100, 0, -20, 60, -80, 80], + ["equal_endpoints", 50, 50, 100, 40, -100, 40], + ["maximum_influence", 0, 100, 0, 100, 0, 100], + ["minimum_maximum", 0, 100, 0, 0.1, 0, 100] +]; +for (i = 0; i <= 20; i++) times.push(i / 20); +try { + comp = app.project.items.addComp("EffectCraft original ease oracle", 64, 64, 1, 2, 20); + layer = comp.layers.addText("Original behavioral probe"); + p = layer.property("ADBE Transform Group").property("ADBE Rotate Z"); + for (i = 0; i < cases.length; i++) { + c = cases[i]; + while (p.numKeys > 0) p.removeKey(p.numKeys); + p.setValueAtTime(0, c[1]); + p.setValueAtTime(1, c[2]); + p.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER); + p.setInterpolationTypeAtKey(2, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER); + p.setTemporalAutoBezierAtKey(1, false); + p.setTemporalAutoBezierAtKey(2, false); + p.setTemporalContinuousAtKey(1, false); + p.setTemporalContinuousAtKey(2, false); + p.setTemporalEaseAtKey(1, [new KeyframeEase(0, 100 / 3)], [new KeyframeEase(c[3], c[4])]); + p.setTemporalEaseAtKey(2, [new KeyframeEase(c[5], c[6])], [new KeyframeEase(0, 100 / 3)]); + values = []; + for (j = 0; j < times.length; j++) values.push(p.valueAtTime(times[j], true)); + results.push({name:c[0], from:c[1], to:c[2], outSpeed:c[3], outInfluence:c[4], inSpeed:c[5], inInfluence:c[6], times:times, values:values, applied:{outSpeed:p.keyOutTemporalEase(1)[0].speed, outInfluence:p.keyOutTemporalEase(1)[0].influence, inSpeed:p.keyInTemporalEase(2)[0].speed, inInfluence:p.keyInTemporalEase(2)[0].influence, outAuto:p.keyTemporalAutoBezier(1), inAuto:p.keyTemporalAutoBezier(2), outContinuous:p.keyTemporalContinuous(1), inContinuous:p.keyTemporalContinuous(2)}}); + } + output = {aeVersion:app.version, cases:results}; + comp.remove(); + comp = null; + return JSON.stringify(output); +} catch (e) { + if (comp !== null) comp.remove(); + throw e; +} diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs new file mode 100644 index 00000000..3d4f1c02 --- /dev/null +++ b/examples/check-ae-ease.mjs @@ -0,0 +1,44 @@ +// Clean-room scalar property comparison. Raw host observations remain gitignored. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cli = process.argv[2]; +if (!cli || !fs.existsSync(cli)) throw new Error('Usage: node examples/check-ae-ease.mjs '); +const tolerance = 1e-7; +function run(command, args) { + const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed'); + return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); +} +const ec = run('cargo', ['run', '--quiet', '-p', 'effectcraft-keyframe', '--example', 'ae_ease_samples']); +const ae = run(process.execPath, [cli, 'run', '-f', path.join(root, 'examples', 'ae-ease-oracle.jsx'), '--raw']); +const local = path.join(root, 'plan', 'aftereffects', 'ref', 'ease'); +fs.mkdirSync(local, { recursive: true }); +fs.writeFileSync(path.join(local, 'ae-observations.json'), JSON.stringify(ae, null, 2) + '\n'); +fs.writeFileSync(path.join(local, 'effectcraft-samples.json'), JSON.stringify(ec, null, 2) + '\n'); +if (ae.cases.length !== ec.cases.length) throw new Error('Case count mismatch'); +const scores = ae.cases.map(reference => { + const candidate = ec.cases.find(c => c.name === reference.name); + if (!candidate || reference.values.length !== candidate.values.length) throw new Error('Missing or truncated case: ' + reference.name); + for (const key of ['from', 'to', 'outSpeed', 'outInfluence', 'inSpeed', 'inInfluence']) { + if (!Number.isFinite(reference[key]) || Math.abs(reference[key] - candidate[key]) > 1e-9) throw new Error('Case metadata mismatch: ' + reference.name + '/' + key); + } + for (const key of ['outSpeed', 'outInfluence', 'inSpeed', 'inInfluence']) { + if (!Number.isFinite(reference.applied[key]) || Math.abs(reference.applied[key] - reference[key]) > 1e-9) throw new Error('AE changed requested ease: ' + reference.name + '/' + key); + } + if (reference.applied.outAuto || reference.applied.inAuto || reference.applied.outContinuous || reference.applied.inContinuous) throw new Error('Unexpected automatic ease'); + const errors = reference.values.map((value, i) => { + if (!Number.isFinite(value) || !Number.isFinite(candidate.values[i]) || reference.times[i] !== candidate.times[i]) throw new Error('Invalid or mismatched sample: ' + reference.name); + return Math.abs(value - candidate.values[i]); + }); + const maxAbsoluteError = Math.max(...errors); + return { name: reference.name, samples: errors.length, maxAbsoluteError, pass: maxAbsoluteError <= tolerance }; +}); +const report = { generatedAt: new Date().toISOString(), aeVersion: ae.aeVersion, property: 'Rotation (scalar)', tolerance, cases: scores, pass: scores.every(c => c.pass) }; +fs.mkdirSync(path.join(root, 'docs', 'fidelity'), { recursive: true }); +fs.writeFileSync(path.join(root, 'docs', 'fidelity', 'keyframe-ease.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report, null, 2)); +if (!report.pass) process.exitCode = 1;