G2: improve editing reliability and Windows rendering fidelity

This commit is contained in:
Dexter 2026-10-06 00:00:31 -05:00
parent b46be83ab4
commit 39e6907b5f
101 changed files with 1727 additions and 351 deletions

View File

@ -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();

View File

@ -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) {

View File

@ -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)

View File

@ -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");

View File

@ -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,

View File

@ -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;

View File

@ -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");
}

View File

@ -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;
}

View File

@ -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).

View File

@ -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, .. })));

View File

@ -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);

View File

@ -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();

View File

@ -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]

View File

@ -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");

View File

@ -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");

View File

@ -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, _| {

View File

@ -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);
}

View File

@ -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();

View File

@ -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).

View File

@ -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()))

View File

@ -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();

View File

@ -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]

View File

@ -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}");

View File

@ -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 }

View File

@ -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;

View File

@ -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);

View File

@ -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 })

View File

@ -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];

View File

@ -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).

View File

@ -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;

View File

@ -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);
}

View File

@ -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 {

View File

@ -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: &params, 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));
}

View File

@ -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: &params, 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]);
}
}
}

View File

@ -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());

View File

@ -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(())
}

View File

@ -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() {

View File

@ -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));

View File

@ -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);

View File

@ -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];

View File

@ -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)

View File

@ -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();

View File

@ -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]);

View File

@ -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]));

View File

@ -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;

View File

@ -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]

View File

@ -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)));

View File

@ -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;
}

View File

@ -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]) {

View File

@ -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,
}

View File

@ -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));

View File

@ -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()
};

View File

@ -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]]);
}
}

View File

@ -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]

View File

@ -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()

View File

@ -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(),
}
}

View File

@ -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);

View File

@ -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]);

View File

@ -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)
}

View File

@ -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];

View File

@ -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 {

View File

@ -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) })
}

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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" {

View File

@ -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()),

View File

@ -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];
}

View File

@ -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
}

View File

@ -433,14 +433,21 @@ impl EffectcraftApp {
}
self.gpu_checked = true;
let Some(rs) = frame.wgpu_render_state() else { return };
match effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()) {
Ok(g) => {
// Backend validation can panic on shader compilation even when device creation
// succeeded. Keep the document and the egui renderer, and use CPU compositing.
let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
match gpu {
Ok(Ok(g)) => {
log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g));
self.session.accel = Some(Arc::new(g.clone()));
self.gpu = Some(g);
self.wgpu = Some(rs.clone());
}
Err(e) => log::info!("GPU compositor unavailable: {e}"),
Ok(Err(e)) => log::info!("GPU compositor unavailable: {e}"),
Err(e) => {
let reason = e.downcast_ref::<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}");
}
}
}
@ -461,7 +468,7 @@ impl EffectcraftApp {
/// and returns `None`; a later call (the next frame) has the pixels.
pub fn viewer_pixels(&mut self) -> Option<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()
@ -511,7 +518,7 @@ impl EffectcraftApp {
// play back, and frames cached by a preview stay on screen when it stops.
let full = self.ui.viewer.res.scale(zoom, ppp);
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto {
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
@ -585,7 +592,7 @@ impl EffectcraftApp {
/// Render options of viewer frames of `comp` at `scale`.
pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts {
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting);
let roi = self.viewer_region(comp);
RenderOpts {
scale,
@ -814,7 +821,9 @@ impl EffectcraftApp {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
0
} else {
(self.frames_parallelism() / 2).max(2) as i64
// Keep workers available for a newly edited viewer frame. Running obsolete
// background renders cannot be preempted by the priority queue.
self.frames_parallelism().saturating_sub(1).min(2) as i64
};
let mut queued = self.frames.inflight();
let series = self.frame_series(cid, scale);
@ -1042,7 +1051,7 @@ impl EffectcraftApp {
// on the UI thread (`Frames::pump`)
return self.frames.remote_slots().max(1);
}
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)
self.frames.parallelism()
}
// ---------------------------------------------------------------- control channel
@ -1252,8 +1261,14 @@ impl EffectcraftApp {
self.command_inbox = Some(rx);
}
menus::handle_shortcuts(self, &ctx);
if !ctx.input(|i| i.pointer.primary_down()) {
self.ui.viewer.property_interacting = false;
}
let t = self.tokens;
let full = ui.max_rect();
if self.dialog.is_some() {
ui.disable();
}
ui.painter().rect_filled(full, 0.0, t.app_bg);
let mut top = full.min.y;
if self.ui.show_menu_bar {
@ -1288,7 +1303,7 @@ impl EffectcraftApp {
let t = &self.tokens;
let galley = ui.painter().layout_no_wrap(m, Tokens::ui(12.0), t.text);
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0), galley.size() + egui::vec2(24.0, 14.0));
ui.painter().rect_filled(r, 6.0, egui::Color32::from_rgba_premultiplied(30, 30, 30, 235));
ui.painter().rect_filled(r, 6.0, t.panel_bg);
ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text);
let resp = ui.interact(r, egui::Id::new("toast"), egui::Sense::click());

View File

@ -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(&params) && 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, &params) {
@ -1117,6 +1146,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;
}
@ -1254,6 +1303,28 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
/// Dispatch keyboard shortcuts (skipped while typing in a text field).
pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
// Dialog cancellation owns Escape even when a text field has keyboard focus.
if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
return;
}
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if app.dialog == Some(crate::Dialog::LayerStyles) {
crate::panels::layer_styles_dialog::finish(app, false);
} else {
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
{
app.session.prefs = p;
app.session.prefs_changed();
}
if app.dialog == Some(crate::Dialog::UnsavedChanges) {
app.dialog_state.unsaved.command = None;
}
app.dialog = None;
}
return;
}
if ctx.egui_wants_keyboard_input() {
return;
}
@ -1310,17 +1381,18 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
// The Project panel has its own selection; a previously selected Timeline layer
// must not be deleted while the Project panel has focus.
if matches!(key, egui::Key::Delete | egui::Key::Backspace) && !mods.any() {
let command = if app.ui.focused == PanelKind::Project {
if app.session.state.project_selection.is_empty() {
continue;
if matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_)) && !app.session.state.camera_points.is_empty() {
if let Err(e) = run_engine(app, ctx, "camera.deletePoints", json!({})) {
app.ui.status = e;
}
"project.delete"
} else {
"edit.clear"
};
if let Err(e) = invoke(app, ctx, command, json!({})) {
app.ui.status = e;
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key));
continue;
}
// The queue panel consumes the event after its selection state is drawn.
if app.ui.focused == PanelKind::RenderQueue {
continue;
}
let _ = invoke(app, ctx, "edit.clear", json!({}));
continue;
}
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {

View File

@ -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()

View File

@ -147,18 +147,18 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value)
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
// Dim the app.
let screen = ctx.content_rect();
ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120));
egui::Area::new(egui::Id::new(("modal", title))).order(egui::Order::Foreground).fixed_pos(screen.center() - size / 2.0).show(ctx, |ui| {
egui::Frame::window(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0));
let size = size.min(available);
egui::Modal::new(egui::Id::new(("modal", title)))
.backdrop_color(Color32::from_black_alpha(120))
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
ui.set_width(size.x - 36.0);
ui.set_min_height(size.y - 36.0);
ui.label(egui::RichText::new(title).font(Tokens::semibold(15.0)).color(t.tab_text_active));
ui.add_space(10.0);
body(ui);
egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body);
});
});
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
@ -228,11 +228,12 @@ fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
} else if resp.hovered() {
t.hover
} else {
Color32::from_rgb(0x2b, 0x2b, 0x2b)
t.field_bg
},
);
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, Color32::WHITE);
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), Color32::WHITE);
let foreground = if discord { Color32::WHITE } else { t.text };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, foreground);
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), foreground);
p.text(
pos2(r.max.x - 12.0, r.center().y),
Align2::RIGHT_CENTER,

View File

@ -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;

View File

@ -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
}

View File

@ -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))
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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()
{

View File

@ -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| {

View File

@ -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());

View File

@ -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 {

View File

@ -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}));
}
_ => {}
}

View File

@ -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 });
});

View File

@ -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)]

View File

@ -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],

View File

@ -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;

View File

@ -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 });

View File

@ -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(&params).contains(&7));
}
#[test]
fn viewer_points_selection_and_effect_controls() {
let (a, plate, uid) = app();

View File

@ -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.

View File

@ -76,7 +76,7 @@ fn delete_and_backspace_remove_project_items_and_undo() {
fn project_multi_selection_deletes_together_and_undoes_once() {
let (mut h, items, keep) = harness();
for (i, item) in items.iter().enumerate() {
click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::SHIFT });
click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::COMMAND });
}
assert_eq!(h.state().session.state.project_selection, items);
let project = h.state().session.project.clone();

View File

@ -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());

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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])
}

57
docs/fidelity/README.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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
}

View File

@ -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.

View File

@ -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 |

View File

@ -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