Merge PR #95: Improve AE-style editing, easing fidelity and Windows GPU rendering
This commit is contained in:
commit
98872a4690
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec<u8>, max_side: u32) -> Result<Vec<u8
|
|||
let mut out = Vec::new();
|
||||
let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive);
|
||||
let raw = img.into_raw();
|
||||
let r = if raw.chunks_exact(4).all(|p| p[3] == 255) {
|
||||
let rgb: Vec<u8> = 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<u8> = 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)
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec<u32> {
|
|||
let raw = std::fs::read(&output).expect("decoded yuv");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let samples: Vec<u16> =
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ fn description(t: Option<&[u8]>) -> Option<String> {
|
|||
// The first record: UTF-16BE.
|
||||
let len = be32(t, 20)? as usize;
|
||||
let off = be32(t, 24)? as usize;
|
||||
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ pub fn load_peaks(file: &Path) -> Option<Vec<[f32; 4]>> {
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
[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, .. })));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]);
|
||||
assert_eq!(segs.iter().map(|(_, p)| PathBuf::from(p)).collect::<Vec<_>>(), [PathBuf::from("/o/Main_001.mp4"), PathBuf::from("/o/Main_002.mp4")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<Option<MutexGuard<'static, ()>>> = 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, _| {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
[("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();
|
||||
|
|
|
|||
|
|
@ -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<Vec<f32>> {
|
|||
/// (mono = the average of left and right).
|
||||
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec<f32> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ fn write_still<W: std::io::Write + std::io::Seek>(
|
|||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb: Vec<u8> = 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<W: std::io::Write + std::io::Seek>(
|
|||
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()))
|
||||
|
|
|
|||
|
|
@ -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<f32> = out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let dec: Vec<f32> = 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<f32> = (0..len).map(|i| dec[i * c + ch]).collect();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -387,9 +387,11 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
|||
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<Rendere
|
|||
let px = r.width as u64 * r.height as u64;
|
||||
let db = read_buffer(&mut e, &r.depth, px * 4)?;
|
||||
let depth = db
|
||||
.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 >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
|
|
@ -812,7 +816,9 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
dst.copy_from_slice(&a.bytes);
|
||||
let depth = b
|
||||
.bytes
|
||||
.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 >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
|
|
|
|||
|
|
@ -509,6 +509,12 @@ impl GpuContext {
|
|||
pub async fn request() -> Result<GpuContext, String> {
|
||||
#[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;
|
||||
|
|
|
|||
|
|
@ -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<f32>) -> 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);
|
||||
|
|
|
|||
|
|
@ -233,8 +233,10 @@ fn sim_key(req: &SimRequest) -> u128 {
|
|||
|
||||
/// Live particles of a state buffer's bytes.
|
||||
fn parse(bytes: &[u8]) -> Vec<SimParticle> {
|
||||
let f: Vec<f32> = 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<f32> = 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 })
|
||||
|
|
|
|||
|
|
@ -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<f32>, b: f32) -> vec3<f32> {
|
||||
let q = a / b;
|
||||
let r = fma(-q, vec3<f32>(b), a);
|
||||
return fma(r, vec3<f32>(1.0 / b), q);
|
||||
}
|
||||
|
||||
// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6.
|
||||
fn fxk_straight(px: vec4<f32>) -> vec3<f32> {
|
||||
if (px.w > 1e-6) {
|
||||
return px.xyz / px.w;
|
||||
return fxk_div3(px.xyz, px.w);
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
|
@ -457,7 +464,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
let s = px.xyz / px.w;
|
||||
let s = fxk_div3(px.xyz, px.w);
|
||||
var r = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < 3u; i++) {
|
||||
let v = s[i];
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ fn fxn_pixel(gid: vec3<u32>) -> vec2<i32> {
|
|||
}
|
||||
|
||||
// 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<f32>, b: vec4<f32>) -> vec4<f32> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -453,10 +453,15 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
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<f32>(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;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if ((flags & 2u) != 0u) {
|
||||
d = vec4<f32>(0.0);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(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<u32>) {
|
|||
if (stencil && (flags & 1u) == 0u) {
|
||||
d = blend_pixel(mode, d, vec4<f32>(0.0), 0.5);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(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<u32>) {
|
|||
if (s.w > 0.0) {
|
||||
d = d + s * P.f[3].y;
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
|
||||
return;
|
||||
}
|
||||
if (s.w <= 0.0 && !stencil) {
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(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<u32>) {
|
|||
if (mode == 1u || mode == 2u) {
|
||||
n = hash_noise(u32(x), u32(y), seed);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z);
|
||||
store_q(vec2<i32>(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<u32>) {
|
|||
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<u32>) {
|
|||
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<u32>) {
|
|||
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<f32>, levels: f32) -> vec4<f32> {
|
||||
let inv = 1.0 / levels;
|
||||
let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv;
|
||||
fn quantize_px(s: vec4<f32>, levels: f32, inv: f32) -> vec4<f32> {
|
||||
// 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<f32>(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<i32>, v: vec4<f32>, levels: f32) {
|
||||
fn store_q(p: vec2<i32>, v: vec4<f32>, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf> = 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());
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error>> {
|
||||
let times: Vec<f64> = (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<f64> = times
|
||||
.iter()
|
||||
.map(|t| evaluate(&keys, Tick::from_seconds_f64(*t), false).map(|v| v.as_f64()).ok_or("missing keyframe evaluation"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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<Keyframe>, 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() {
|
||||
|
|
|
|||
|
|
@ -145,12 +145,8 @@ fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f6
|
|||
};
|
||||
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
|
||||
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
|
||||
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;
|
||||
}
|
||||
// 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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
|
|||
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<Px>) -
|
|||
// 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];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -393,7 +393,9 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> 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<Option<Primit
|
|||
let attrs = p.get("attributes").ok_or_else(|| perr("primitive without attributes"))?;
|
||||
let Some(pa) = idx(attrs, "POSITION") else { return Ok(None) };
|
||||
let (pos, _) = d.floats(pa)?;
|
||||
let positions: Vec<[f32; 3]> = 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<usize>| -> Result<Vec<[f32; 3]>> {
|
||||
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<u32> = match idx(p, "indices") {
|
||||
|
|
@ -540,7 +542,7 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
|||
indices.extend([raw[0], raw[i - 1], raw[i]]);
|
||||
}
|
||||
}
|
||||
_ => indices.extend(raw.chunks_exact(3).flatten()),
|
||||
_ => indices.extend(raw.as_chunks::<3>().0.iter().flatten()),
|
||||
}
|
||||
if normals.len() != n {
|
||||
normals.clear();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> 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]));
|
||||
|
|
|
|||
|
|
@ -198,9 +198,9 @@ fn compute_caps(m: &Mode) -> Vec<u8> {
|
|||
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<u8> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ fn deep_point(bp: &BezPath) -> Option<kurbo::Point> {
|
|||
_ => {}
|
||||
});
|
||||
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)));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u16> = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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]) {
|
||||
|
|
|
|||
|
|
@ -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<f64> = 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ impl Type1 {
|
|||
let hex = body.iter().take(4).all(u8::is_ascii_hexdigit);
|
||||
let raw: Vec<u8> = if hex {
|
||||
let digits: Vec<u8> = 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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ impl P<'_> {
|
|||
}
|
||||
}
|
||||
if raw.starts_with(&[0xFE, 0xFF]) {
|
||||
let u: Vec<u16> = raw[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<usize> = (0..tris.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
let (ta, za) = self.tri_depth[a];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -340,9 +340,9 @@ fn unpickle(p: &[u8]) -> Result<Obj> {
|
|||
/// Element bytes and conversion of a storage dtype.
|
||||
fn to_f32(dtype: &str, bytes: &[u8]) -> Option<Vec<f32>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Result<Vec<_>>>()?;
|
||||
Op::Pad(pads)
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ fn native_name(b: &[u8], id: u16) -> Option<String> {
|
|||
continue;
|
||||
}
|
||||
let raw = b.get(storage + off..storage + off + len)?;
|
||||
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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<String> {
|
|||
let raw = b.get(storage + off..storage + off + len)?;
|
||||
let (rank, s) = match plat {
|
||||
3 if enc == 1 || enc == 10 => {
|
||||
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = 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()),
|
||||
|
|
|
|||
|
|
@ -301,9 +301,8 @@ pub fn sym_eigen(a: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
|
|||
/// 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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> = 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<u8> = 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -435,14 +435,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::<String>().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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -463,7 +470,7 @@ impl EffectcraftApp {
|
|||
/// and returns `None`; a later call (the next frame) has the pixels.
|
||||
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
|
||||
let to_image = |bytes: Vec<u8>, 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()
|
||||
|
|
@ -513,7 +520,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()));
|
||||
}
|
||||
|
|
@ -587,7 +594,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,
|
||||
|
|
@ -816,7 +823,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);
|
||||
|
|
@ -1044,7 +1053,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
|
||||
|
|
@ -1254,8 +1263,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 {
|
||||
|
|
@ -1290,7 +1305,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());
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<u64> = 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<u64>, Option<u64>)>(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) {
|
||||
|
|
@ -1121,6 +1150,26 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool>
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -1258,6 +1307,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;
|
||||
}
|
||||
|
|
@ -1314,17 +1385,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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -149,18 +149,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) {
|
||||
|
|
@ -231,11 +231,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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ControlType>, p: &Property, v: &KV) -> Option<Value> {
|
||||
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::<String>(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ pub fn decode_thumb(text: &str) -> Option<egui::ColorImage> {
|
|||
return None;
|
||||
}
|
||||
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::<Result<_, _>>().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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ pub struct LayerStyleState {
|
|||
pub layer: u64,
|
||||
pub selected: String,
|
||||
pub undo_mark: usize,
|
||||
checkpoint: Option<Checkpoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Checkpoint {
|
||||
project: std::sync::Arc<effectcraft_engine::project::Project>,
|
||||
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<bool,
|
|||
return Ok(false);
|
||||
}
|
||||
let mark = app.session.history.undo.len();
|
||||
let checkpoint = checkpoint(app);
|
||||
app.session.execute(id, json!({})).map_err(|e| e.to_string())?;
|
||||
open(app, &json!({"style": style}))?;
|
||||
// Cancel also removes the style just added.
|
||||
app.dialog_state.layer_style.undo_mark = mark;
|
||||
app.dialog_state.layer_style.checkpoint = Some(checkpoint);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -214,13 +235,17 @@ pub fn select(app: &mut EffectcraftApp, style: &str) {
|
|||
|
||||
/// Close the dialog: OK keeps the edits, Cancel undoes everything done since it opened.
|
||||
pub fn finish(app: &mut EffectcraftApp, ok: bool) {
|
||||
if !ok {
|
||||
let mark = app.dialog_state.layer_style.undo_mark;
|
||||
while app.session.history.undo.len() > 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ItemId>, 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<ItemId>, 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::<ItemId>(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<u64> = 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();
|
||||
|
|
|
|||
|
|
@ -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::<crate::panels::DragPayload>(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<u64> = 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<u64>, Option<u64>) = 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -428,32 +428,34 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
let mut acts: Vec<Act> = 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| {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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::<LayerId>(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::<Vec<_>>()
|
||||
})
|
||||
} 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::<Rect>(egui::Id::new("tl-layer-box")))
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("tl-layer-box")));
|
||||
let layers: Vec<u64> = 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::<PickWhip>(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 {
|
||||
|
|
|
|||
|
|
@ -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<u64> =
|
||||
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::<Vec<(u64, String)>>(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<u64> = 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}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -88,7 +88,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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LayerId> = 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<LayerId> = 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<LayerId> = 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::<Vec<_>>(), 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<_>>(), 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<LayerId> = 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<LayerId> = 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<LayerId> = 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::<Vec<_>>()})).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();
|
||||
}
|
||||
|
|
@ -452,6 +452,6 @@ pub fn rgba_to_yuv420(rgba: &[u8], w: u32, h: u32, full_range: bool) -> (Vec<u8>
|
|||
pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let y: Vec<u8> = rgba.chunks_exact(4).take(w * h).map(|p| p[3]).collect();
|
||||
let y: Vec<u8> = rgba.as_chunks::<4>().0.iter().take(w * h).map(|p| p[3]).collect();
|
||||
(y, vec![128; cw * ch], vec![128; cw * ch])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue