From aafe46e091556336f764a7ca54eec34202e1cd56 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:15:01 -0400 Subject: [PATCH 01/20] M12.7: Classic 3D runs on the GPU (per-pixel depth sort, lights, ray-cast shadows, DOF); Auto sends 3D comps to the GPU Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 15 +- crates/gpu/src/classic3d.rs | 244 ++++++++++++++ crates/gpu/src/context.rs | 69 +++- crates/gpu/src/effects.rs | 2 +- crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/classic3d.wgsl | 460 ++++++++++++++++++++++++++ crates/gpu/src/tests.rs | 54 +-- crates/gpu/src/tests_3d.rs | 220 ++++++++++++ crates/gpu/src/walk.rs | 21 +- crates/render/src/lib.rs | 12 +- crates/render/src/tests.rs | 9 +- crates/render/src/three_d/compose.rs | 179 ++++++++-- crates/render/src/three_d/mod.rs | 1 + 13 files changed, 1214 insertions(+), 75 deletions(-) create mode 100644 crates/gpu/src/classic3d.rs create mode 100644 crates/gpu/src/shaders/classic3d.wgsl create mode 100644 crates/gpu/src/tests_3d.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 4f2192fc9..9e1a4a8b7 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -761,7 +761,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { None => s.project.comps().map(|(id, _)| *id).collect(), }; eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu)); - eprintln!("{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view"); + eprintln!( + "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}", + "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu" + ); for cid in comps { let Some(comp) = s.project.comp(cid) else { continue }; let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); @@ -797,7 +800,15 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { gpu.wait(); }); let size = format!("{}x{}", (comp.width as f64 * scale).round(), (comp.height as f64 * scale).round()); - eprintln!("{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2}"); + // Agreement: share of pixels where the GPU frame differs from the CPU reference by + // more than 1/255 on any channel. + let (a, b) = (mk(Backend::Cpu, Some(&cache)), mk(Backend::Gpu, Some(&gcache))); + let off = a.data.iter().zip(&b.data).filter(|(p, q)| (0..4).any(|c| (p[c] - q[c]).abs() > 1.0 / 255.0 + 1e-6)).count(); + let pct = 100.0 * off as f64 / a.data.len().max(1) as f64; + let speedup = cpu_warm / gpu_warm.max(1e-9); + eprintln!( + "{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {pct:>8.3}%" + ); } } Ok(()) diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs new file mode 100644 index 000000000..0d141c921 --- /dev/null +++ b/crates/gpu/src/classic3d.rs @@ -0,0 +1,244 @@ +//! Classic 3D runs on the GPU (`classic3d.wgsl`): the CPU prepares the planes +//! ([`Renderer::prepare_3d_run`](effectcraft_render::Renderer::prepare_3d_run): layer buffers, +//! homographies, materials, lights, track mattes), the GPU blurs depth of field, packs the +//! buffers into one atlas texture and runs the per-pixel depth sort, lighting, ray-cast shadows +//! and blending of `render::three_d::compose` in one kernel. + +use std::collections::HashMap; + +use effectcraft_geom::Mat4; +use effectcraft_project::LightKind; +use effectcraft_render::three_d::{LightState, PlaneGeo, Run3d}; + +use crate::context::{Enc, GpuImage, Params}; +use crate::ops::mode_id; + +/// Fragments kept per pixel (`MAX_FRAGS` in the shader). Runs that could stack more planes on +/// one pixel render on the CPU. +const MAX_FRAGS: usize = 32; + +/// Little-endian words for the shader's storage structs. +#[derive(Default)] +struct Words(Vec); + +impl Words { + fn f(&mut self, v: [f32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn u(&mut self, v: [u32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn d(&mut self, v: [f64; 4]) { + self.f(v.map(|x| x as f32)); + } + /// Pad an empty array to one element (`size` bytes) so its binding is valid. + fn at_least(mut self, size: usize) -> Vec { + if self.0.len() < size { + self.0.resize(size, 0); + } + self.0 + } +} + +fn affine_rows(w: &mut Words, m: &Mat4) { + for r in 0..3 { + w.d(m.0[r]); + } +} + +/// Shelf-pack rectangles into an atlas no larger than `max` on a side: (width, height, +/// positions). +fn pack(sizes: &[(u32, u32)], max: u32) -> Option<(u32, u32, Vec<(u32, u32)>)> { + let maxw = sizes.iter().map(|s| s.0).max()?; + if maxw > max { + return None; + } + let area: f64 = sizes.iter().map(|s| s.0 as f64 * s.1 as f64).sum(); + let limit = maxw.max((area.sqrt() * 1.25).ceil() as u32).min(max); + let mut order: Vec = (0..sizes.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(sizes[i].1)); + let mut pos = vec![(0, 0); sizes.len()]; + let (mut x, mut y, mut shelf, mut width) = (0u32, 0u32, 0u32, 0u32); + for i in order { + let (w, h) = sizes[i]; + if x + w > limit { + y += shelf; + x = 0; + shelf = 0; + } + pos[i] = (x, y); + x += w; + shelf = shelf.max(h); + width = width.max(x); + } + let height = y + shelf; + (height <= max).then_some((width.max(1), height.max(1), pos)) +} + +/// Upper bound of the planes drawn on any one pixel: a pixel covered by a set of planes lies in +/// each one's box, so every plane of the set overlaps the box of any member. +fn max_overlap(boxes: &[[i64; 4]]) -> usize { + boxes.iter().map(|a| boxes.iter().filter(|b| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]).count()).max().unwrap_or(0) +} + +fn light_kind(k: LightKind) -> u32 { + match k { + LightKind::Ambient | LightKind::Environment => 0, + LightKind::Parallel => 1, + LightKind::Point => 2, + LightKind::Spot => 3, + } +} + +fn write_geo(w: &mut Words, g: &PlaneGeo) { + // The homography is defined up to scale: normalise it (by a positive factor, keeping the + // sign tests) into f32's comfortable range. + let m = g.hinv.0.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs())); + let k = if m > 0.0 { 1.0 / m } else { 1.0 }; + for r in 0..3 { + w.d([g.hinv.0[r][0] * k, g.hinv.0[r][1] * k, g.hinv.0[r][2] * k, 0.0]); + } + w.d([g.depth[0], g.depth[1], g.depth[2], 0.0]); + affine_rows(w, &g.world); + w.d([g.normal.x, g.normal.y, g.normal.z, 0.0]); + w.d([g.cam.eye.x, g.cam.eye.y, g.cam.eye.z, if g.cam.ortho { 1.0 } else { 0.0 }]); + let f = g.cam.forward(); + w.d([f.x, f.y, f.z, 0.0]); +} + +fn write_light(w: &mut Words, l: &LightState) { + w.u([light_kind(l.kind), l.falloff, l.casts_shadows as u32, 0]); + w.d([l.pos.x, l.pos.y, l.pos.z, 0.0]); + w.d([l.dir.x, l.dir.y, l.dir.z, 0.0]); + w.f([l.color[0], l.color[1], l.color[2], 0.0]); + w.d([l.cone_half, l.feather, l.radius, l.falloff_distance]); + w.d([l.shadow_darkness, l.shadow_diffusion, 0.0, 0.0]); +} + +const PLANE_BYTES: usize = 12 * 16; +const GEO_BYTES: usize = 10 * 16; +const LIGHT_BYTES: usize = 6 * 16; + +/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the +/// caller draws it on the CPU). +pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { + let (w, h) = (canvas.width, canvas.height); + let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect(); + if boxes.is_empty() { + return Some(canvas.clone()); + } + if max_overlap(&boxes) > MAX_FRAGS + || run.planes.iter().any(|p| p.winv.0[3] != [0.0, 0.0, 0.0, 1.0] || p.geos.iter().any(|g| g.world.0[3] != [0.0, 0.0, 0.0, 1.0])) + { + return None; + } + // Plane textures: the (cached) layer upload, depth-of-field blurred when asked. + let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len()); + for p in &run.planes { + let mut img = e.g.upload_buf(&p.buf)?; + let mut off = p.buf.offset; + if let Some((sigma, pad)) = p.dof { + let (pw, ph) = (img.width + 2 * pad, img.height + 2 * pad); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.image(pw, ph); + e.copy_into(&img, &padded, pad, pad); + img = crate::effects::gaussian_blur(e, &padded, sigma, sigma, false); + off = [off[0] + pad as f64, off[1] + pad as f64]; + } + texs.push((img, off)); + } + let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); + let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; + if (aw as u64) * (ah as u64) * 16 > 2 << 30 { + return None; + } + let atlas = e.image(aw, ah); + for ((t, _), &(x, y)) in texs.iter().zip(&pos) { + e.copy_into(t, &atlas, x, y); + } + // Track mattes (shared by the planes of one layer). + let mut mattes: Vec = vec![]; + let mut matte_ix: HashMap = HashMap::new(); + let n_px = w as usize * h as usize; + let mut planes = Words::default(); + let mut geos = Words::default(); + let mut n_geos = 0u32; + for ((p, (t, off)), &(ax, ay)) in run.planes.iter().zip(&texs).zip(&pos) { + let matte = match &p.matte { + Some(m) if m.len() == n_px => { + let key = std::sync::Arc::as_ptr(m) as usize; + let next = matte_ix.len() as u32 + 1; + let ix = *matte_ix.entry(key).or_insert(next); + if ix == next { + mattes.extend_from_slice(m); + } + ix + } + Some(_) => return None, + None => 0, + }; + planes.f([ax as f32, ay as f32, t.width as f32, t.height as f32]); + planes.d([off[0], off[1], p.buf.scale, if p.bicubic { 1.0 } else { 0.0 }]); + planes.d(p.bbox.map(|v| v as f64)); + planes.d(p.bounds); + affine_rows(&mut planes, &p.winv); + planes.f([p.mat.ambient, p.mat.diffuse, p.mat.specular, p.mat.exponent() as f32]); + planes.f([p.mat.metal, p.mat.light_transmission, p.opacity, 0.0]); + let flags = p.draw as u32 | (p.preserve as u32) << 1 | (p.mat.accepts_lights as u32) << 2; + planes.u([n_geos, p.geos.len() as u32, flags, mode_id(p.mode)]); + planes.u([p.mat.casts_shadows, p.mat.accepts_shadows, p.layer_id, p.order as u32]); + planes.u([matte, run.seed ^ p.layer_id, 0, 0]); + for g in &p.geos { + write_geo(&mut geos, g); + } + n_geos += p.geos.len() as u32; + } + if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size as u64 { + return None; + } + let mut lights = Words::default(); + for l in &run.lights { + write_light(&mut lights, l); + } + let mut casters = Words::default(); + for c in &run.casters { + casters.0.extend_from_slice(&(*c as u32).to_le_bytes()); + } + let pb = e.bytes(planes.at_least(PLANE_BYTES)); + let gb = e.bytes(geos.at_least(GEO_BYTES)); + let lb = e.bytes(lights.at_least(LIGHT_BYTES)); + let cb = e.bytes(casters.at_least(16)); + let mb = e.data(&mattes); + let mut p = Params::default(); + p.u[0] = [run.planes.len() as u32, run.lights.len() as u32, run.casters.len() as u32, 0]; + let out = e.image(w, h); + e.dispatch_ext("classic3d", &p, &atlas, Some(canvas), &out, Some(&mb), (w.div_ceil(16), h.div_ceil(16)), Some([&pb, &gb, &lb, &cb])); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shelf_packing_fits_and_does_not_overlap() { + let sizes = [(100, 40), (30, 90), (60, 60), (100, 10), (5, 5)]; + let (w, h, pos) = pack(&sizes, 256).unwrap(); + for (i, (&(x, y), &(sw, sh))) in pos.iter().zip(&sizes).enumerate() { + assert!(x + sw <= w && y + sh <= h); + for (j, (&(x2, y2), &(sw2, sh2))) in pos.iter().zip(&sizes).enumerate() { + if i != j { + assert!(x + sw <= x2 || x2 + sw2 <= x || y + sh <= y2 || y2 + sh2 <= y, "{i} and {j} overlap"); + } + } + } + assert!(pack(&[(300, 10)], 256).is_none()); + assert_eq!(max_overlap(&[[0, 0, 10, 10], [5, 5, 15, 15], [20, 20, 30, 30]]), 2); + } +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 468681040..d549149d4 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -28,6 +28,10 @@ const ENTRIES: &[&str] = &[ "pointwise", ]; +/// Entry points that also bind group 1 (four read-only storage buffers; see +/// [`Enc::dispatch_ext`]). +const EXT_ENTRIES: &[&str] = &["classic3d"]; + /// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; @@ -88,6 +92,7 @@ pub struct GpuContext { pub(crate) name: String, pub(crate) max_dim: u32, bgl: wgpu::BindGroupLayout, + bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, @@ -135,7 +140,7 @@ impl GpuContext { } } let name = format!("{} ({:?})", info.name, info.backend); - let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")].concat(); + let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/classic3d.wgsl")].concat(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -167,12 +172,29 @@ impl GpuContext { bind_group_layouts: &[Some(&bgl)], immediate_size: 0, }); + let storage = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl_ext = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + entries: &[storage(0), storage(1), storage(2), storage(3)], + }); + let layout_ext = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)], + immediate_size: 0, + }); let pipelines = ENTRIES .iter() - .map(|e| { + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| { let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(e), - layout: Some(&layout), + layout: Some(layout), module: &module, entry_point: Some(e), compilation_options: Default::default(), @@ -221,6 +243,7 @@ impl GpuContext { name, max_dim, bgl, + bgl_ext, pipelines, display_bgl, display, @@ -249,7 +272,7 @@ impl GpuContext { let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })) .ok()?; - device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}"))); + device.on_uncaptured_error(Arc::new(|e| eprintln!("wgpu: {e}"))); GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok() } @@ -374,6 +397,22 @@ impl<'g> Enc<'g> { out: &GpuImage, data: Option<&wgpu::Buffer>, groups: (u32, u32), + ) { + self.dispatch_ext(entry, p, src, aux, out, data, groups, None); + } + + /// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers. + #[allow(clippy::too_many_arguments)] + pub fn dispatch_ext( + &mut self, + entry: &str, + p: &Params, + src: &GpuImage, + aux: Option<&GpuImage>, + out: &GpuImage, + data: Option<&wgpu::Buffer>, + groups: (u32, u32), + ext: Option<[&wgpu::Buffer; 4]>, ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { @@ -395,11 +434,26 @@ impl<'g> Enc<'g> { wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).as_entire_binding() }, ], }); + let bg_ext = ext.map(|b| { + g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &g.bgl_ext, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: b[0].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: b[1].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b[2].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: b[3].as_entire_binding() }, + ], + }) + }); let enc = self.encoder(); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None }); pass.set_pipeline(pipe); pass.set_bind_group(0, &bg, &[]); + if let Some(b) = &bg_ext { + pass.set_bind_group(1, b, &[]); + } pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1); } self.pending += 1; @@ -525,7 +579,12 @@ impl<'g> Enc<'g> { for x in v { bytes.extend_from_slice(&x.to_le_bytes()); } - while bytes.len() < 16 { + self.bytes(bytes) + } + + /// A read-only storage buffer holding `bytes` (padded to 16 bytes). + pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { + while bytes.len() < 16 || bytes.len() % 4 != 0 { bytes.push(0); } self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b33..b1cc308ff 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -111,7 +111,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b } /// raster::gaussian_blur (3 box passes per axis). -fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { +pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] }; let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] }; box_passes(e, img, &rx, &ry, repeat) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 43474e9d2..7a2634b3d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -22,6 +22,7 @@ //! egui-wgpu to draw. mod adv3d; +mod classic3d; mod context; mod effects; mod ops; @@ -140,4 +141,6 @@ impl Accelerator for Gpu { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_3d; +#[cfg(test)] mod tests_adv3d; diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl new file mode 100644 index 000000000..4e8d21397 --- /dev/null +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -0,0 +1,460 @@ +// Classic 3D on the GPU: the per-pixel compositor of `render::three_d::compose` (draw_run). +// +// Every plane covering an output pixel is inverse-mapped through its homography (one per +// motion-blur sub-sample), sampled from the plane atlas (`src`), shaded by the comp's lights +// (Blinn-Phong, ray-cast shadows against the caster planes) and multiplied by its track matte; +// the pixel's fragments are insertion-sorted far → near (coplanar fragments in stack order) and +// blended over the canvas (`aux`) with each layer's mode and opacity. +// +// Bindings: group 0 as every kernel (P, src = atlas, aux = canvas, out, data = track mattes); +// group 1 = planes, geometries, lights, caster indices. +// P.u[0] = (planes, lights, casters, _) + +struct Plane { + // Atlas rectangle (x, y, w, h) in texels. + atlas: vec4, + // Buffer offset (x, y), scale, bicubic (0 / 1). + tex: vec4, + // Output pixel bounds [x0, y0, x1, y1). + bbox: vec4, + // Layer-space bounds of the buffer. + bounds: vec4, + // World → layer (affine rows). + winv0: vec4, + winv1: vec4, + winv2: vec4, + // Ambient, diffuse, specular, Blinn-Phong exponent. + mat0: vec4, + // Metal, light transmission, opacity. + mat1: vec4, + // First geometry, geometry count, flags (1 draw, 2 preserve transparency, 4 accepts + // lights), blend mode. + ints: vec4, + // Casts shadows, accepts shadows (0 off, 1 on, 2 only), layer id, stack order. + ints2: vec4, + // Track matte (index + 1; 0 = none), dissolve seed. + ints3: vec4, +} + +struct Geo { + // Output pixel → homogeneous layer coordinates (rows). + h0: vec4, + h1: vec4, + h2: vec4, + // Camera depth = d.x·u + d.y·v + d.z. + depth: vec4, + // Layer → world (affine rows). + w0: vec4, + w1: vec4, + w2: vec4, + // Unit world normal. + normal: vec4, + // Camera eye (xyz) and orthographic flag (w). + eye: vec4, + // Camera forward (xyz). + fwd: vec4, +} + +struct Light { + // Kind (0 ambient, 1 parallel, 2 point, 3 spot), falloff, casts shadows. + ints: vec4, + pos: vec4, + dir: vec4, + color: vec4, + // Cone half-angle, feather, radius, falloff distance. + cone: vec4, + // Shadow darkness, shadow diffusion. + shadow: vec4, +} + +@group(1) @binding(0) var planes: array; +@group(1) @binding(1) var geos: array; +@group(1) @binding(2) var lights: array; +@group(1) @binding(3) var casters: array; + +const NEAR3: f32 = 1.0; +const MAX_FRAGS: u32 = 32u; + +fn atlas_get(i: u32, x: i32, y: i32) -> vec4 { + let r = planes[i].atlas; + if (x < 0 || y < 0 || x >= i32(r.z) || y >= i32(r.w)) { + return vec4(0.0); + } + return textureLoad(src, vec2(i32(r.x) + x, i32(r.y) + y), 0); +} + +// Image::sample_bilinear / sample_bicubic on a plane's buffer at layer coordinates (u, v). +fn texel(i: u32, u: f32, v: f32) -> vec4 { + let t = planes[i].tex; + let x = u * t.z + t.x; + let y = v * t.z + t.y; + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let xi = i32(x0); + let yi = i32(y0); + if (t.w > 0.5) { + let wx = cubic_w(fx - x0); + let wy = cubic_w(fy - y0); + var o = vec4(0.0); + for (var j = 0; j < 4; j++) { + for (var k = 0; k < 4; k++) { + o += atlas_get(i, xi - 1 + k, yi - 1 + j) * (wx[k] * wy[j]); + } + } + return max(o, vec4(0.0)); + } + let tx = fx - x0; + let ty = fy - y0; + let a = atlas_get(i, xi, yi); + let b = atlas_get(i, xi + 1, yi); + let c = atlas_get(i, xi, yi + 1); + let d = atlas_get(i, xi + 1, yi + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn affine(r0: vec4, r1: vec4, r2: vec4, p: vec3) -> vec3 { + return vec3(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w); +} + +fn linear3(r0: vec4, r1: vec4, r2: vec4, v: vec3) -> vec3 { + return vec3(dot(r0.xyz, v), dot(r1.xyz, v), dot(r2.xyz, v)); +} + +// compose::unproject: (u, v, depth, ok). +fn unproject(g: u32, sx: f32, sy: f32) -> vec4 { + let gm = geos[g]; + let ortho = gm.eye.w > 0.5; + let qz = gm.h2.x * sx + gm.h2.y * sy + gm.h2.z; + if ((!ortho && qz <= 0.0) || qz == 0.0) { + return vec4(0.0); + } + let u = (gm.h0.x * sx + gm.h0.y * sy + gm.h0.z) / qz; + let v = (gm.h1.x * sx + gm.h1.y * sy + gm.h1.z) / qz; + let z = gm.depth.x * u + gm.depth.y * v + gm.depth.z; + if (!ortho && z < NEAR3) { + return vec4(0.0); + } + return vec4(u, v, z, 1.0); +} + +// LightState::to_light: unit vector towards the light (xyz) and the distance (w; -1 = ∞). +fn to_light(l: u32, p: vec3) -> vec4 { + let li = lights[l]; + if (li.ints.x == 1u) { + return vec4(-li.dir.xyz, -1.0); + } + let d = li.pos.xyz - p; + let len = length(d); + if (len < 1e-9) { + return vec4(0.0, 0.0, -1.0, 0.0); + } + return vec4(d / len, len); +} + +fn smoothstep01(t: f32) -> f32 { + return 1.0 - t * t * (3.0 - 2.0 * t); +} + +// LightState::attenuation. +fn attenuation(l: u32, p: vec3) -> f32 { + let li = lights[l]; + let tl = to_light(l, p); + let dist = tl.w; + let finite = dist >= 0.0; + let radius = li.cone.z; + let fd = li.cone.w; + var k = 1.0; + if (li.ints.y == 1u && finite) { + if (dist <= radius) { + k = 1.0; + } else if (fd <= 0.0) { + k = 0.0; + } else { + k = smoothstep01(clamp((dist - radius) / fd, 0.0, 1.0)); + } + } else if (li.ints.y == 2u && finite) { + if (dist <= radius || (radius <= 0.0 && dist <= 1.0)) { + k = 1.0; + } else { + let q = max(radius, 1.0) / dist; + k = q * q; + } + } + if (li.ints.x == 3u) { + let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0); + let a = acos(cos_a); + let outer = li.cone.x; + let inner = outer * (1.0 - li.cone.y); + if (a <= inner) { + } else if (a >= outer) { + k = 0.0; + } else { + k *= smoothstep01((a - inner) / max(outer - inner, 1e-9)); + } + } + return k; +} + +// compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. +fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { + let pl = planes[c]; + let li = lights[l]; + let a = affine(pl.winv0, pl.winv1, pl.winv2, p); + var hit: vec3; + var d_pc: f32; + var d_cl: f32; + if (li.ints.x == 1u) { + // Towards the light, 1e6 away (in direction form: exact in f32). + let dl = linear3(pl.winv0, pl.winv1, pl.winv2, -li.dir.xyz); + if (dl.z == 0.0) { + return vec3(1.0); + } + let s = -a.z / dl.z; + if (s <= 1.0 || s >= 1.0e6) { + return vec3(1.0); + } + hit = a + dl * s; + d_pc = length(li.dir.xyz) * s; + d_cl = 1000.0; + } else { + let b = affine(pl.winv0, pl.winv1, pl.winv2, li.pos.xyz); + if (a.z == b.z || (a.z < 0.0) == (b.z < 0.0)) { + return vec3(1.0); + } + let t = a.z / (a.z - b.z); + if (t <= 1e-6 || t >= 1.0) { + return vec3(1.0); + } + hit = a + (b - a) * t; + let seg = length(li.pos.xyz - p); + d_pc = seg * t; + d_cl = max(seg * (1.0 - t), 1.0); + } + let r = li.shadow.y * min(d_pc / d_cl, 4.0); + let bd = pl.bounds; + if (hit.x < bd.x - r || hit.y < bd.y - r || hit.x > bd.z + r || hit.y > bd.w + r) { + return vec3(1.0); + } + var px = vec4(0.0); + if (r * pl.tex.z < 0.5) { + px = texel(c, hit.x, hit.y); + } else { + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0; + } + } + } + let alpha = clamp(px.w * pl.mat1.z, 0.0, 1.0); + if (alpha <= 0.0) { + return vec3(1.0); + } + let tr = pl.mat1.y; + let dark = li.shadow.x; + var o = vec3(1.0); + for (var k = 0; k < 3; k++) { + var st = 0.0; + if (px.w > 0.0) { + st = clamp(px[k] / px.w, 0.0, 1.0); + } + let through = (1.0 - alpha) + alpha * tr * st; + o[k] = 1.0 - dark * (1.0 - through); + } + return o; +} + +fn shadow_of(it: u32, l: u32, p: vec3) -> vec3 { + var tr = vec3(1.0); + let n = P.u[0].z; + let lid = planes[it].ints2.z; + for (var k = 0u; k < n; k++) { + let c = casters[k]; + if (c == it || planes[c].ints2.z == lid) { + continue; + } + tr *= occlusion(c, p, l); + } + return tr; +} + +// compose::shade_texel (light::shade). +fn shade_texel(it: u32, g: u32, u: f32, v: f32, t: vec4) -> vec4 { + let pl = planes[it]; + let n_lights = P.u[0].y; + let accepts_shadows = pl.ints2.y; + if (n_lights == 0u || (pl.ints.z & 4u) == 0u) { + if (accepts_shadows == 2u) { + return vec4(0.0); + } + return t; + } + let gm = geos[g]; + let a = t.w; + let rgb = t.xyz / a; + let p = affine(gm.w0, gm.w1, gm.w2, vec3(u, v, 0.0)); + var to_viewer: vec3; + if (gm.eye.w > 0.5) { + to_viewer = -gm.fwd.xyz; + } else { + to_viewer = normalize(gm.eye.xyz - p); + } + var n = gm.normal.xyz; + if (dot(n, to_viewer) < 0.0) { + n = -n; + } + var amb = vec3(0.0); + var diff = vec3(0.0); + var spec = vec3(0.0); + var shadow_amt = 0.0; + let ex = pl.mat0.w; + let metal = pl.mat1.x; + let transmission = pl.mat1.y; + let specular = pl.mat0.z; + for (var i = 0u; i < n_lights; i++) { + let li = lights[i]; + if (li.ints.x == 0u) { + amb += li.color.xyz; + continue; + } + let k = attenuation(i, p); + if (k <= 0.0) { + continue; + } + let lv = to_light(i, p).xyz; + let ndl = dot(n, lv); + let front = ndl > 0.0; + var lambert = ndl; + if (!front) { + lambert = -ndl * transmission; + } + if (lambert <= 0.0) { + continue; + } + var sh = vec3(1.0); + if (li.ints.z != 0u && accepts_shadows != 0u) { + sh = shadow_of(it, i, p); + } + shadow_amt = max(shadow_amt, 1.0 - (sh.x + sh.y + sh.z) / 3.0); + diff += li.color.xyz * (lambert * k) * sh; + if (front && specular > 0.0) { + let h = normalize(lv + to_viewer); + let s = powz(max(dot(n, h), 0.0), ex) * k; + let tint = vec3(1.0) + (rgb - vec3(1.0)) * metal; + spec += li.color.xyz * s * sh * tint; + } + } + let lit = rgb * (pl.mat0.x * amb + pl.mat0.y * diff) + specular * spec; + if (accepts_shadows == 2u) { + return vec4(0.0, 0.0, 0.0, a * shadow_amt); + } + return vec4(lit * a, a); +} + +// compose::before: a draws before b (farther; coplanar → lower stack order first). +fn before(za: f32, oa: u32, zb: f32, ob: u32) -> bool { + let tol = 1e-5 * max(max(abs(za), abs(zb)), 1.0); + if (abs(za - zb) <= tol) { + return oa < ob; + } + return za > zb; +} + +@compute @workgroup_size(16, 16) +fn classic3d(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let x = i32(gid.x); + let y = i32(gid.y); + if (x >= dims.x || y >= dims.y) { + return; + } + let pix = vec2(x, y); + var d = textureLoad(aux, pix, 0); + let fx = f32(x); + let fy = f32(y); + let sx = fx + 0.5; + let sy = fy + 0.5; + var fz: array; + var fo: array; + var fi: array; + var fp: array, MAX_FRAGS>; + var nf = 0u; + let n_planes = P.u[0].x; + for (var i = 0u; i < n_planes; i++) { + let pl = planes[i]; + if ((pl.ints.z & 1u) == 0u) { + continue; + } + if (fx < pl.bbox.x || fx >= pl.bbox.z || fy < pl.bbox.y || fy >= pl.bbox.w) { + continue; + } + // compose::fragment: average of the motion-blur sub-samples. + let g0 = pl.ints.x; + let ng = pl.ints.y; + var acc = vec4(0.0); + var depth = 0.0; + var hit = false; + for (var k = 0u; k < ng; k++) { + let q = unproject(g0 + k, sx, sy); + if (q.w == 0.0) { + continue; + } + let t = texel(i, q.x, q.y); + if (t.w <= 1e-6) { + continue; + } + if (!hit) { + depth = q.z; + hit = true; + } + acc += shade_texel(i, g0 + k, q.x, q.y, t); + } + if (!hit) { + continue; + } + if (ng > 1u) { + acc *= 1.0 / f32(ng); + } + let m = pl.ints3.x; + if (m != 0u) { + let f = data[(m - 1u) * u32(dims.x) * u32(dims.y) + u32(y) * u32(dims.x) + u32(x)]; + acc *= f; + } + if (!(acc.w > 0.0 || acc.x != 0.0 || acc.y != 0.0 || acc.z != 0.0) || nf >= MAX_FRAGS) { + continue; + } + // Insertion: far → near, coplanar in stack order (stable, as the CPU's insertion sort). + let order = pl.ints2.w; + var j = nf; + while (j > 0u && before(depth, order, fz[j - 1u], fo[j - 1u])) { + fz[j] = fz[j - 1u]; + fo[j] = fo[j - 1u]; + fi[j] = fi[j - 1u]; + fp[j] = fp[j - 1u]; + j--; + } + fz[j] = depth; + fo[j] = order; + fi[j] = i; + fp[j] = acc; + nf++; + } + for (var k = 0u; k < nf; k++) { + let pl = planes[fi[k]]; + var op = pl.mat1.z; + if ((pl.ints.z & 2u) != 0u) { + op *= d.w; + } + let s = fp[k] * op; + let mode = pl.ints.w; + var n = 0.5; + if (mode == 1u || mode == 2u) { + n = hash_noise(u32(x), u32(y), pl.ints3.y); + } + d = blend_pixel(mode, d, s, n); + } + textureStore(out, pix, d); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index c6fd84f0d..137fdec3d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -26,7 +26,7 @@ use effectcraft_time::{FrameRate, Tick}; use crate::Gpu; -fn gpu() -> Option<&'static Gpu> { +pub(crate) fn gpu() -> Option<&'static Gpu> { static G: OnceLock> = OnceLock::new(); G.get_or_init(|| { let g = Gpu::headless(); @@ -40,9 +40,9 @@ fn gpu() -> Option<&'static Gpu> { /// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and /// fully transparent regions (different per item). -struct Pattern; +pub(crate) struct Pattern; -fn pattern(seed: u32, w: u32, h: u32) -> Image { +pub(crate) fn pattern(seed: u32, w: u32, h: u32) -> Image { let mut img = Image::new(w, h); let k = seed as f32 * 0.37; for y in 0..h { @@ -66,14 +66,14 @@ impl FootageSource for Pattern { } } -struct Scene { - p: Project, - cid: ItemId, - comp: Comp, +pub(crate) struct Scene { + pub(crate) p: Project, + pub(crate) cid: ItemId, + pub(crate) comp: Comp, } impl Scene { - fn new(depth: BitDepth) -> Scene { + pub(crate) fn new(depth: BitDepth) -> Scene { let mut p = Project::default(); p.settings.bit_depth = depth; let comp = Comp::new(97, 61, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); @@ -81,7 +81,7 @@ impl Scene { Scene { p, cid, comp } } - fn footage(&mut self, w: u32, h: u32) -> Layer { + pub(crate) fn footage(&mut self, w: u32, h: u32) -> Layer { let f = Footage { path: "pattern.png".into(), kind: FootageKind::Still, @@ -106,19 +106,19 @@ impl Scene { build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None) } - fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { + pub(crate) fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { let sid = self.p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); build::layer(&mut self.p, &self.comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) } /// Add on top of the stack. - fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { + pub(crate) fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { let id = l.id; self.p.comp_mut(self.cid).unwrap().layers.insert(0, l); id } - fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { + pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { let spec = effectcraft_effects::find(id).unwrap(); let mut next = self.p.next_id; let size = effectcraft_render::source_size(&self.p, l); @@ -131,27 +131,27 @@ impl Scene { } } -fn set(l: &mut Layer, path: &str, v: Value) { +pub(crate) fn set(l: &mut Layer, path: &str, v: Value) { l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; } -fn v3(x: f64, y: f64) -> Value { +pub(crate) fn v3(x: f64, y: f64) -> Value { Value::Vec3([x, y, 0.0]) } /// Differences of a GPU render against the CPU render. #[derive(Debug)] -struct Diff { - max: f32, +pub(crate) struct Diff { + pub(crate) max: f32, /// Pixels over the tolerance. - over: usize, - total: usize, - worst: (u32, u32, [f32; 4], [f32; 4]), + pub(crate) over: usize, + pub(crate) total: usize, + pub(crate) worst: (u32, u32, [f32; 4], [f32; 4]), /// 8/16 bpc render. - quantized: bool, + pub(crate) quantized: bool, } -fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { +pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { assert_eq!((cpu.width, cpu.height), (gpu.width, gpu.height), "size"); let mut d = Diff { max: 0.0, over: 0, total: cpu.data.len(), worst: (0, 0, [0.0; 4], [0.0; 4]), quantized: false }; for (i, (a, b)) in cpu.data.iter().zip(&gpu.data).enumerate() { @@ -169,7 +169,7 @@ fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { d } -fn tolerance(depth: BitDepth) -> f32 { +pub(crate) fn tolerance(depth: BitDepth) -> f32 { match depth { BitDepth::Bpc8 => 1.0 / 255.0 + 1e-6, _ => 1e-3, @@ -177,7 +177,7 @@ fn tolerance(depth: BitDepth) -> f32 { } /// Render on the CPU and the GPU; `Some(diff)` (None without an adapter). -fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { +pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { let g = gpu()?; let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts }); @@ -188,12 +188,12 @@ fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { Some(d) } -fn opts() -> RenderOpts { +pub(crate) fn opts() -> RenderOpts { RenderOpts { motion_blur: true, ..Default::default() } } /// Assert agreement; `allow` = fraction of pixels allowed over the tolerance. -fn check(label: &str, d: Option, allow: f64) { +pub(crate) fn check(label: &str, d: Option, allow: f64) { let Some(d) = d else { return }; let frac = d.over as f64 / d.total as f64; // Quantised depths: rounding-boundary flips (see the module docs). @@ -417,11 +417,11 @@ fn effect_case(id: &str, vals: &[(&str, Value)]) { } } -fn n(v: f64) -> Value { +pub(crate) fn n(v: f64) -> Value { Value::Scalar(v) } -fn c(r: f64, g: f64, b: f64) -> Value { +pub(crate) fn c(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs new file mode 100644 index 000000000..f10368d20 --- /dev/null +++ b/crates/gpu/src/tests_3d.rs @@ -0,0 +1,220 @@ +//! Classic 3D on the GPU vs the CPU compositor: intersecting planes, blend modes, lights, +//! ray-cast shadows, depth of field, motion blur, track mattes, collapsed precomps and +//! adjustment layers splitting a run. Same tolerances as `tests.rs`; pixels on the exact +//! intersection line of two planes may sort differently (f32 vs f64 depths), so intersecting +//! scenes allow 0.5 % of pixels over. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKind, MatteKind, TrackMatte}; +use effectcraft_render::{Backend, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set}; + +const W: u32 = 160; +const H: u32 = 100; + +fn scene(depth: BitDepth) -> Scene { + let mut s = Scene::new(depth); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + *s.p.comp_mut(s.cid).unwrap() = comp.clone(); + s.comp = comp; + s +} + +fn three_d(mut l: Layer, pos: [f64; 3], rot_y: f64) -> Layer { + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +fn camera(s: &mut Scene, pos: [f64; 3], dof: Option<(f64, f64)>) { + let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(W as f64 * 50.0 / 36.0)); + set(&mut cam, "transform/position", Value::Vec3(pos)); + set(&mut cam, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + if let Some((focus, aperture)) = dof { + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(aperture)); + } + s.push(cam); +} + +fn light(s: &mut Scene, kind: LightKind, pos: [f64; 3], vals: &[(&str, Value)]) { + let mut l = build::layer(&mut s.p, &s.comp, "Light", LayerSource::Light { kind }, (W, H), None); + if kind != LightKind::Ambient { + set(&mut l, "transform/position", Value::Vec3(pos)); + } + if l.props.prop_mut("transform/poi").is_some() { + set(&mut l, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + } + for (k, v) in vals { + set(&mut l, &format!("lightOptions/{k}"), v.clone()); + } + s.push(l); +} + +/// Background, a far plane, and two planes crossing each other (one in `mode`). +fn crossing(depth: BitDepth, mode: BlendMode) -> Scene { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let far = three_d(s.footage(150, 90), [80.0, 50.0, 120.0], 0.0); + s.push(far); + let a = three_d(s.footage(90, 70), [80.0, 50.0, 0.0], 35.0); + s.push(a); + let mut b = three_d(s.solid([0.9, 0.5, 0.2], 90, 70), [80.0, 52.0, 0.0], -35.0); + b.blend_mode = mode; + set(&mut b, "transform/opacity", Value::Scalar(80.0)); + s.push(b); + s +} + +#[test] +fn intersecting_planes_and_blend_modes() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Multiply, BlendMode::Add, BlendMode::Dissolve] { + let mut s = crossing(depth, mode); + check(&format!("crossing {mode:?} {depth:?} (default camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + camera(&mut s, [30.0, 20.0, -260.0], None); + check(&format!("crossing {mode:?} {depth:?} (camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} + +#[test] +fn lights_specular_transmission_and_shadows() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // Every layer casts shadows; the crossing pair transmits light. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.switches.three_d { + set(l, "materialOptions/castsShadows", Value::Enum(1)); + set(l, "materialOptions/specularIntensity", Value::Scalar(60.0)); + set(l, "materialOptions/specularShininess", Value::Scalar(30.0)); + set(l, "materialOptions/lightTransmission", Value::Scalar(40.0)); + } + } + camera(&mut s, [-20.0, -10.0, -250.0], None); + light(&mut s, LightKind::Ambient, [0.0, 0.0, 0.0], &[("intensity", n(25.0))]); + light( + &mut s, + LightKind::Spot, + [20.0, -60.0, -150.0], + &[("castsShadows", Value::Bool(true)), ("shadowDarkness", n(80.0)), ("coneAngle", n(70.0)), ("coneFeather", n(40.0)), ("intensity", n(120.0))], + ); + light( + &mut s, + LightKind::Point, + [150.0, 20.0, -80.0], + &[ + ("castsShadows", Value::Bool(true)), + ("shadowDiffusion", n(6.0)), + ("falloff", Value::Enum(1)), + ("radius", n(120.0)), + ("falloffDistance", n(200.0)), + ], + ); + light(&mut s, LightKind::Parallel, [-100.0, -100.0, -100.0], &[("castsShadows", Value::Bool(true)), ("color", Value::Color([0.5, 0.7, 1.0, 1.0]))]); + check(&format!("lights + shadows {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Accepts Shadows: Only, Accepts Lights off, Casts Shadows: Only. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + match l.name.as_str() { + "Pattern" => set(l, "materialOptions/acceptsShadows", Value::Enum(2)), + "Solid" if l.switches.three_d => set(l, "materialOptions/acceptsLights", Value::Bool(false)), + _ => {} + } + } + check(&format!("shadow-only materials {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn depth_of_field_and_motion_blur() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((220.0, 30.0))); + check(&format!("dof {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Animated plane with motion blur (sub-sampled geometry, equal weights). + let mut s = scene(depth); + let mut l = three_d(s.footage(60, 40), [40.0, 50.0, 0.0], 20.0); + l.switches.motion_blur = true; + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([40.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([140.0, 40.0, -60.0]))]; + s.push(l); + s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true; + light(&mut s, LightKind::Point, [80.0, 0.0, -100.0], &[]); + check(&format!("3d motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.5)), 0.005); + } +} + +#[test] +fn mattes_preserve_transparency_precomps_and_adjustment_splits() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // A 2D luma matte for the top plane, preserve transparency on the far plane. + let m = s.solid([0.7, 0.7, 0.7], 70, 50); + let mid = s.push(m); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); + if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + far.preserve_transparency = true; + } + // Move the matte below the bottom so the 3D run stays one run. + let ml = layers.remove(0); + layers.push(ml); + layers.last_mut().unwrap().switches.video = false; + check(&format!("matte + preserve {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // An adjustment layer in the middle of the run splits it. + let mut s = crossing(depth, BlendMode::Normal); + let mut adj = s.solid([1.0, 1.0, 1.0], W, H); + adj.switches.adjustment = true; + adj.switches.three_d = true; + s.effect(&mut adj, "ec.channel.invert", &[]); + s.p.comp_mut(s.cid).unwrap().layers.insert(1, adj); + check(&format!("adjustment split {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // A collapsed 3D precomp: its layers join the parent's 3D space. + let mut s = crossing(depth, BlendMode::Normal); + let inner = Comp::new(80, 60, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let mut a = s.footage(50, 40); + a.switches.three_d = true; + set(&mut a, "transform/rotationX", Value::Scalar(40.0)); + let b = s.solid([0.2, 0.8, 0.4], 30, 20); + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); + pre.switches.collapse = true; + pre.switches.three_d = true; + set(&mut pre, "transform/position", Value::Vec3([70.0, 45.0, -10.0])); + s.push(pre); + check(&format!("collapsed 3d precomp {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn the_gpu_draws_classic_3d_runs_itself() { + let Some(g) = gpu() else { return }; + let s = crossing(BitDepth::Bpc32, BlendMode::Normal); + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let vis = r.visible_layers(&ctx); + let run: Vec<_> = vis.iter().copied().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).expect("a classic run is prepared for the GPU"); + assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run"); + let img = e.download(&out).unwrap(); + let mut cpu = effectcraft_raster::Image::new(W, H); + r.draw_3d_run(&ctx, &run, &mut cpu); + let covered = cpu.data.iter().filter(|p| p[3] > 0.5).count(); + assert!(covered > (W * H / 3) as usize, "the planes cover the frame ({covered} px)"); + let d = crate::tests::diff(&cpu, &img, 1e-3); + assert!(d.over * 200 <= d.total, "{d:?}"); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index ea56df7a2..a0b51f1fd 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -69,7 +69,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut while j < visible.len() && visible[j].is_3d() { j += 1; } - on_cpu(e, canvas, |img| r.draw_3d_run(ctx, &visible[i..j], img))?; + draw_3d(e, r, ctx, &visible[i..j], canvas)?; i = j; } else { match r.collapsed(ctx, visible[i]) { @@ -86,6 +86,25 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut Some(()) } +/// A run of consecutive 3D layers: Classic 3D on the GPU (`classic3d`), with 3D adjustment +/// layers splitting the run as on the CPU; Advanced 3D, wireframes and runs the kernel cannot +/// hold draw on the CPU. +fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], canvas: &mut GpuImage) -> Option<()> { + if let Some(k) = run.iter().position(|l| l.switches.adjustment) { + draw_3d(e, r, ctx, &run[..k], canvas)?; + draw_layer(e, r, ctx, run[k], canvas)?; + return draw_3d(e, r, ctx, &run[k + 1..], canvas); + } + if run.is_empty() { + return Some(()); + } + match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) { + Some(img) => *canvas = img, + None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?, + } + Some(()) +} + fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ff71fb575..2e4028d4a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -494,11 +494,8 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { - // Auto keeps comps with 3D layers on the CPU compositor: 3D runs are still composited on - // the CPU, and the GPU path's readback/upload around them makes such comps slower. - let auto_3d = self.opts.backend == Backend::Auto && self.project.comp(comp_id).is_some_and(|c| c.has_3d()); + // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well. if self.depth == 0 - && !auto_3d && let Some(a) = self.active_accel() && let Some(img) = a.comp_frame(self, comp_id, t) { @@ -1347,6 +1344,13 @@ impl<'a> Renderer<'a> { three_d::compose::draw_run(self, ctx, run, canvas); } + /// A run of consecutive 3D layers prepared for an accelerator (planes, lights, shadow + /// casters; see [`three_d::Run3d`]) at output size `out`. `None` = draw it with + /// [`Self::draw_3d_run`] (adjustment or wireframe layers in the run, Advanced 3D). + pub fn prepare_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], out: (u32, u32)) -> Option { + three_d::compose::gpu_run(self, ctx, run, out) + } + /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { self.draw_layer(ctx, layer, canvas, false); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 02c7f4965..553737141 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -390,7 +390,7 @@ impl crate::Accelerator for ProbeAccel { } #[test] -fn auto_backend_keeps_3d_comps_on_the_cpu() { +fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); p.settings.gpu_acceleration = true; let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50); @@ -404,8 +404,11 @@ fn auto_backend_keeps_3d_comps_on_the_cpu() { }; // 2D comp: Auto uses the GPU compositor. assert!(asked(&p, crate::Backend::Auto)); - // A 3D layer: Auto stays on the CPU; an explicit Gpu request still goes to the GPU. + // A 3D layer: Classic 3D runs on the GPU too (M12.7), so Auto still asks it. p.comp_mut(cid).unwrap().layers[0].switches.three_d = true; - assert!(!asked(&p, crate::Backend::Auto)); + assert!(asked(&p, crate::Backend::Auto)); assert!(asked(&p, crate::Backend::Gpu)); + // Software Only: never. + p.settings.gpu_acceleration = false; + assert!(!asked(&p, crate::Backend::Auto)); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 2647fadd6..37fe59115 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -29,16 +29,18 @@ use crate::{EvalCtx, Renderer}; /// One position of a plane (several with motion blur). #[derive(Clone, Copy, Debug)] -pub(crate) struct Geo { +pub struct Geo { /// Output pixel → homogeneous layer coordinates. - hinv: Mat3, + pub hinv: Mat3, /// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`. - depth: [f64; 3], - world: Mat4, - normal: Vec3, + pub depth: [f64; 3], + /// Layer → world. + pub world: Mat4, + /// Unit world normal. + pub normal: Vec3, /// Output pixel bounds [x0, y0, x1, y1) covered by the plane. - bbox: [i64; 4], - cam: CameraState, + pub bbox: [i64; 4], + pub cam: CameraState, } /// A prepared 3D layer. @@ -60,6 +62,9 @@ pub(crate) struct Item<'a> { winv: Mat4, bounds: [f64; 4], bbox: [i64; 4], + /// Depth of field left to an accelerator: Gaussian sigma and padding (buffer pixels) of the + /// blur `buf` still needs (`None` = `buf` is final). + dof: Option<(f64, u32)>, } impl Item<'_> { @@ -72,7 +77,7 @@ impl Item<'_> { } /// Build the plane geometry of a layer for a camera. -fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { +fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); @@ -89,7 +94,6 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, return None; } let normal = normal.normalize(); - let b = buf_bounds(buf); let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])]; let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); let mut behind = 0; @@ -170,20 +174,25 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, defer_dof: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; - prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) + prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY, defer_dof) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { + prepare_all_opt(r, ctx, layer, order, out, in_run, false) +} + +/// [`prepare_all`], optionally leaving the depth-of-field blur to an accelerator. +fn prepare_all_opt<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, defer_dof: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m, defer_dof)) .collect(); } - prepare(r, ctx, layer, order, out, in_run).into_iter().collect() + prepare(r, ctx, layer, order, out, in_run, defer_dof).into_iter().collect() } /// Prepare a plane showing `buf`, whose space maps into the layer through `local`. @@ -197,6 +206,7 @@ fn prepare_with<'a>( in_run: bool, mut buf: Arc, local: Mat4, + defer_dof: bool, ) -> Option> { if buf.img.is_empty() { return None; @@ -207,6 +217,8 @@ fn prepare_with<'a>( let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); let world = outer * ctx.world_matrix(layer) * local; // Depth of field: blur the layer by its circle of confusion at its centre. + let mut bounds = buf_bounds(&buf); + let mut deferred = None; if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { let b = buf_bounds(&buf); let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0)); @@ -218,18 +230,27 @@ fn prepare_with<'a>( if radius > 0.3 { let sigma = radius / 1.5; let pad = (sigma * 3.0).ceil() as u32 + 1; - let padded = buf.img.padded(pad); - // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. - buf = Arc::new(Buf { - img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), - offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], - scale: buf.scale, - }); + if defer_dof { + let s = buf.scale.max(1e-9); + let (w, h) = ((buf.img.width + 2 * pad) as f64, (buf.img.height + 2 * pad) as f64); + let o = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + bounds = [-o[0] / s, -o[1] / s, (w - o[0]) / s, (h - o[1]) / s]; + deferred = Some((sigma, pad)); + } else { + let padded = buf.img.padded(pad); + // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. + buf = Arc::new(Buf { + img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), + offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], + scale: buf.scale, + }); + bounds = buf_bounds(&buf); + } } } let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { - geo(&cam, world, &buf, comp, s, out).into_iter().collect() + geo(&cam, world, bounds, comp, s, out).into_iter().collect() } else { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; @@ -239,7 +260,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out) }) .collect() }; @@ -251,7 +272,6 @@ fn prepare_with<'a>( bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])]; } let mat = Material::of(ctx, layer); - let bounds = buf_bounds(&buf); Some(Item { layer, order, @@ -266,6 +286,7 @@ fn prepare_with<'a>( winv: world.inverse().unwrap_or(Mat4::IDENTITY), bounds, bbox, + dof: deferred, }) } @@ -330,6 +351,23 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); + let g = gather(r, ctx, run, out, false); + let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32); + for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + r.draw_layer(ctx, l, canvas, false); + } +} + +/// The prepared planes of a run: drawn items (stack order), shadow casters outside the run, and +/// the lights. +struct Gathered<'a> { + items: Vec>, + extra: Vec>, + lights: Vec, +} + +fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, u32), defer_dof: bool) -> Gathered<'a> { // A collapsed precomp's 3D layers are lit by the outer comp's lights. let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent)); let mut items: Vec = run @@ -340,9 +378,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + return collapsed_items(r, ctx, l, item, i, out, defer_dof); } - let mut its = prepare_all(r, ctx, l, i, out, true); + let mut its = prepare_all_opt(r, ctx, l, i, out, true, defer_dof); if !its.is_empty() { let m = matte_for(r, ctx, l, out); for it in &mut its { @@ -362,15 +400,11 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) - .flat_map_iter(|l| prepare_all(r, ctx, l, 0, out, false)) + .flat_map_iter(|l| prepare_all_opt(r, ctx, l, 0, out, false, defer_dof)) .collect(); } items.sort_by_key(|i| i.order); - let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); - composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); - for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { - r.draw_layer(ctx, l, canvas, false); - } + Gathered { items, extra, lights } } /// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's @@ -384,6 +418,7 @@ fn collapsed_items<'a>( item: effectcraft_project::ItemId, order: usize, out: (u32, u32), + defer_dof: bool, ) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; @@ -401,7 +436,7 @@ fn collapsed_items<'a>( if nctx.opacity(l) <= 0.0 { continue; } - let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true); + let mut its = prepare_all_opt(&sub, &nctx, l, order + 1 + k.min(1022), out, true, defer_dof); if !its.is_empty() { let m = matte_for(&sub, &nctx, l, out); for it in &mut its { @@ -590,6 +625,86 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { out } +// ------------------------------------------------------------------ accelerator export + +/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane +/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and +/// ray-cast shadows of [`draw_run`] on another device. +#[derive(Clone, Debug)] +pub struct Run3d { + /// Drawn planes in stack order, then shadow casters from outside the run (`draw` false). + pub planes: Vec, + /// Indices (into `planes`) of the shadow casters, in the CPU's order. + pub casters: Vec, + pub lights: Vec, + /// Dissolve seed of the run. + pub seed: u32, +} + +/// One plane of a [`Run3d`]. +#[derive(Clone, Debug)] +pub struct Plane3d { + /// The layer buffer (blending space). When `dof` is set, the plane shows this buffer padded + /// by `dof.1` pixels on every side and Gaussian-blurred with sigma `dof.0` + /// (`raster::gaussian_blur`, no edge repeat). + pub buf: Arc, + pub dof: Option<(f64, u32)>, + pub bicubic: bool, + /// One per motion-blur sub-sample (equal weights). + pub geos: Vec, + pub mat: Material, + pub opacity: f32, + pub draw: bool, + /// Screen-space track matte factor (output size, row-major). + pub matte: Option>>, + pub preserve: bool, + /// World → layer, and the (blurred) buffer's layer-space bounds. + pub winv: Mat4, + pub bounds: [f64; 4], + /// Output pixel bounds of all geometries. + pub bbox: [i64; 4], + /// Stack order (coplanar fragments draw lower orders first). + pub order: usize, + pub layer_id: u32, + pub mode: BlendMode, +} + +/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU +/// (adjustment or wireframe layers, Advanced 3D). +pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option { + if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.switches.quality == effectcraft_project::Quality::Wireframe) { + return None; + } + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + return None; + } + let g = gather(r, ctx, run, out, true); + let planes: Vec = g + .items + .into_iter() + .chain(g.extra) + .map(|it| Plane3d { + layer_id: it.layer.id.0 as u32, + mode: it.layer.blend_mode, + buf: it.buf, + dof: it.dof, + bicubic: it.bicubic, + geos: it.geos, + mat: it.mat, + opacity: it.opacity, + draw: it.draw, + matte: it.matte, + preserve: it.preserve, + winv: it.winv, + bounds: it.bounds, + bbox: it.bbox, + order: it.order, + }) + .collect(); + let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect(); + Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 }) +} + // ------------------------------------------------------------------ auxiliary render pass /// One surface seen through an output pixel (aux pass). diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index e97c67fee..4bd7dcf42 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -16,6 +16,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; +pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; From b8a56eb4a934c4d945156a7296435e7f46a734ae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:21:14 -0400 Subject: [PATCH 02/20] M13.6: scopes, content-aware fill (PatchMatch/flow propagation/membrane), cut detection and auto-trace algorithms Co-Authored-By: Claude Opus 5.5 --- crates/path/src/lib.rs | 1 + crates/path/src/trace.rs | 417 +++++++++++++++ crates/raster/src/cuts.rs | 206 ++++++++ crates/raster/src/inpaint.rs | 958 +++++++++++++++++++++++++++++++++++ crates/raster/src/lib.rs | 3 + crates/raster/src/scopes.rs | 552 ++++++++++++++++++++ 6 files changed, 2137 insertions(+) create mode 100644 crates/path/src/trace.rs create mode 100644 crates/raster/src/cuts.rs create mode 100644 crates/raster/src/inpaint.rs create mode 100644 crates/raster/src/scopes.rs diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index b4216e888..169a7428c 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -11,6 +11,7 @@ mod fit; pub mod interp; pub mod offset; pub mod ops; +pub mod trace; pub mod varstroke; use effectcraft_geom::Mat3; diff --git a/crates/path/src/trace.rs b/crates/path/src/trace.rs new file mode 100644 index 000000000..429738032 --- /dev/null +++ b/crates/path/src/trace.rs @@ -0,0 +1,417 @@ +//! Auto-trace: outlines of a scalar field (a layer's alpha, a colour channel or luminance) as +//! Bezier paths (Layer ▸ Auto-trace). +//! +//! 1. **Marching squares** over the samples at pixel centres, with linear interpolation of the +//! threshold crossing on each cell edge (saddles resolved by the cell's mean), on a field padded +//! with "outside" so every contour closes. +//! 2. **Douglas–Peucker** simplification of each closed loop to the Tolerance (pixels). +//! 3. **Bezier fitting**: each simplified span is fitted with one cubic (least squares with fixed +//! end tangents, Newton reparameterisation; [`crate::fit`]). The tangent at each vertex is the +//! chord direction at Corner Roundness 0 (straight segments, sharp corners) and the smooth +//! (Catmull–Rom) direction at 100 %, blended between. +//! +//! Loops smaller than Minimum Area are dropped. Each loop gets its nesting depth (0 = outermost), +//! so callers can add outer outlines and subtract holes. + +use std::collections::HashMap; + +use effectcraft_keyframe::ShapePath; +use kurbo::{Point, Vec2}; + +use crate::fit::{fit_single, unit}; + +/// Auto-trace options. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TraceOpts { + /// Inside where the field is at least this (0…1). + pub threshold: f32, + /// Douglas–Peucker tolerance in pixels. + pub tolerance: f64, + /// Loops with a smaller area (square pixels) are dropped. + pub min_area: f64, + /// 0 = polygons, 1 = fully smooth. + pub corner_roundness: f64, +} + +impl Default for TraceOpts { + fn default() -> Self { + TraceOpts { threshold: 0.5, tolerance: 1.0, min_area: 10.0, corner_roundness: 0.5 } + } +} + +/// A traced outline. +#[derive(Clone, Debug, PartialEq)] +pub struct Traced { + pub path: ShapePath, + /// Number of other outlines that contain this one (even = filled, odd = hole). + pub depth: usize, + /// Signed area of the simplified polygon (pixels²). + pub area: f64, +} + +/// Trace `field` (`w × h`, row-major, sample (x, y) at pixel centre (x + 0.5, y + 0.5)). +pub fn trace(field: &[f32], w: usize, h: usize, o: &TraceOpts) -> Vec { + if w == 0 || h == 0 || field.len() < w * h { + return vec![]; + } + let loops = contours(field, w, h, o.threshold); + let mut polys: Vec<(Vec<[f64; 2]>, Vec<[f64; 2]>, f64)> = loops + .into_iter() + .filter_map(|raw| { + let a = signed_area(&raw); + if a.abs() < o.min_area.max(0.0) || raw.len() < 3 { + return None; + } + let simp = simplify_closed(&raw, o.tolerance.max(0.01)); + if simp.len() < 3 { + return None; + } + Some((raw, simp, a)) + }) + .collect(); + // Depth: how many other loops contain a point of this loop. + let depths: Vec = (0..polys.len()) + .map(|i| { + let p = polys[i].1[0]; + (0..polys.len()).filter(|&j| j != i && polys[j].2.abs() > polys[i].2.abs() && point_in_poly(p, &polys[j].1)).count() + }) + .collect(); + let mut out: Vec = polys + .drain(..) + .zip(depths) + .map(|((raw, simp, a), depth)| Traced { path: fit_loop(&raw, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a }) + .collect(); + // Outer outlines first, then holes, islands…; larger first within a depth. + out.sort_by(|a, b| a.depth.cmp(&b.depth).then(b.area.abs().total_cmp(&a.area.abs()))); + out +} + +/// Marching squares: closed polylines (pixel coordinates). +fn contours(field: &[f32], w: usize, h: usize, thr: f32) -> Vec> { + // Padded grid: sample (i, j) of the padded grid is pixel (i - 1, j - 1), outside = -inf. + let (gw, gh) = (w + 2, h + 2); + let val = |i: usize, j: usize| -> f32 { if i == 0 || j == 0 || i > w || j > h { f32::NEG_INFINITY } else { field[(j - 1) * w + (i - 1)] } }; + let inside = |i: usize, j: usize| val(i, j) >= thr; + // Edge ids: horizontal edge (i, j)-(i+1, j) = 2 * (j * gw + i); vertical (i, j)-(i, j+1) = +1. + let pos = |i: usize, j: usize| [i as f64 - 0.5, j as f64 - 0.5]; + let cross = |a: (usize, usize), b: (usize, usize)| -> [f64; 2] { + let (va, vb) = (val(a.0, a.1), val(b.0, b.1)); + let t = if va.is_finite() && vb.is_finite() && (vb - va).abs() > 1e-12 { + ((thr - va) / (vb - va)).clamp(0.0, 1.0) as f64 + } else if va.is_finite() { + // b is padding: the crossing sits half a pixel out. + if va >= thr { 0.5 } else { 0.0 } + } else if vb >= thr { + 0.5 + } else { + 1.0 + }; + let (pa, pb) = (pos(a.0, a.1), pos(b.0, b.1)); + [pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t] + }; + let hid = |i: usize, j: usize| 2 * (j * gw + i); + let vid = |i: usize, j: usize| 2 * (j * gw + i) + 1; + // Directed segments (from edge, to edge) keeping the inside on the left. + let mut next: HashMap = HashMap::new(); + let mut point: HashMap = HashMap::new(); + for j in 0..gh - 1 { + for i in 0..gw - 1 { + // Corners: tl (i, j), tr (i+1, j), br (i+1, j+1), bl (i, j+1). + let (tl, tr, br, bl) = (inside(i, j), inside(i + 1, j), inside(i + 1, j + 1), inside(i, j + 1)); + let code = (tl as u8) << 3 | (tr as u8) << 2 | (br as u8) << 1 | bl as u8; + if code == 0 || code == 15 { + continue; + } + let top = hid(i, j); + let bottom = hid(i, j + 1); + let left = vid(i, j); + let right = vid(i + 1, j); + let mut seg = |a: usize, b: usize| { + next.insert(a, b); + }; + // Walk with the inside on the right in image coordinates (y down), which is + // counter-clockwise on screen for outer loops… orientation is fixed afterwards. + match code { + 1 => seg(left, bottom), + 2 => seg(bottom, right), + 3 => seg(left, right), + 4 => seg(right, top), + 5 => { + // tr + bl inside (saddle). + let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0; + if centre >= thr { + seg(left, top); + seg(right, bottom); + } else { + seg(left, bottom); + seg(right, top); + } + } + 6 => seg(bottom, top), + 7 => seg(left, top), + 8 => seg(top, left), + 9 => seg(top, bottom), + 10 => { + // tl + br inside (saddle). + let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0; + if centre >= thr { + seg(top, right); + seg(bottom, left); + } else { + seg(top, left); + seg(bottom, right); + } + } + 11 => seg(top, right), + 12 => seg(right, left), + 13 => seg(right, bottom), + 14 => seg(bottom, left), + _ => {} + } + for (e, a, b) in [(top, (i, j), (i + 1, j)), (bottom, (i, j + 1), (i + 1, j + 1)), (left, (i, j), (i, j + 1)), (right, (i + 1, j), (i + 1, j + 1))] + { + if inside(a.0, a.1) != inside(b.0, b.1) { + point.entry(e).or_insert_with(|| cross(a, b)); + } + } + } + } + let mut out = vec![]; + let mut keys: Vec = next.keys().copied().collect(); + keys.sort_unstable(); + for start in keys { + if !next.contains_key(&start) { + continue; + } + let mut poly = vec![]; + let mut e = start; + while let Some(n) = next.remove(&e) { + if let Some(p) = point.get(&e) { + poly.push(*p); + } + e = n; + if e == start { + break; + } + } + if poly.len() >= 3 { + out.push(poly); + } + } + out +} + +/// Shoelace area (positive = clockwise on screen with y down). +pub fn signed_area(p: &[[f64; 2]]) -> f64 { + let n = p.len(); + let mut a = 0.0; + for i in 0..n { + let (q, r) = (p[i], p[(i + 1) % n]); + a += q[0] * r[1] - r[0] * q[1]; + } + a * 0.5 +} + +fn point_in_poly(p: [f64; 2], poly: &[[f64; 2]]) -> bool { + let mut inside = false; + let n = poly.len(); + let mut j = n - 1; + for i in 0..n { + let (a, b) = (poly[i], poly[j]); + if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] { + inside = !inside; + } + j = i; + } + inside +} + +fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + ((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt() +} + +/// Douglas–Peucker on an open polyline: kept indices. +fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, keep: &mut Vec) { + if hi <= lo + 1 { + return; + } + let (mut best, mut bi) = (0.0, lo); + for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) { + let d = seg_dist(*q, p[lo], p[hi]); + if d > best { + best = d; + bi = k; + } + } + if best > tol { + dp(p, lo, bi, tol, keep); + keep.push(bi); + dp(p, bi, hi, tol, keep); + } +} + +/// Douglas–Peucker on a closed loop: indices into `p` of the kept vertices, in order. +fn simplify_idx(p: &[[f64; 2]], tol: f64) -> Vec { + let n = p.len(); + // Split at the two mutually farthest-ish points (first point and the point farthest from it). + let far = (0..n).max_by(|&a, &b| { + let d = |i: usize| (p[i][0] - p[0][0]).powi(2) + (p[i][1] - p[0][1]).powi(2); + d(a).total_cmp(&d(b)) + }); + let far = far.unwrap_or(0); + let mut ext: Vec<[f64; 2]> = p.to_vec(); + ext.push(p[0]); + let mut keep = vec![0]; + dp(&ext, 0, far, tol, &mut keep); + keep.push(far); + dp(&ext, far, n, tol, &mut keep); + keep.sort_unstable(); + keep.dedup(); + keep.retain(|&i| i < n); + keep +} + +fn simplify_closed(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> { + simplify_idx(p, tol).into_iter().map(|i| p[i]).collect() +} + +/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour). +fn fit_loop(raw: &[[f64; 2]], simp: &[[f64; 2]], round: f64) -> ShapePath { + let n = simp.len(); + if round <= 1e-6 { + return ShapePath::polygon(simp, true); + } + // Raw index of each simplified vertex (they are raw points). + let idx: Vec = simp.iter().map(|s| raw.iter().position(|r| r == s).unwrap_or(0)).collect(); + let pt = |a: [f64; 2]| Point::new(a[0], a[1]); + let v = |a: [f64; 2], b: [f64; 2]| Vec2::new(b[0] - a[0], b[1] - a[1]); + // Per vertex: (direction leaving, direction arriving), blended between chord and smooth. + let dirs: Vec<(Vec2, Vec2)> = (0..n) + .map(|i| { + let (prev, cur, nxt) = (simp[(i + n - 1) % n], simp[i], simp[(i + 1) % n]); + let smooth = unit(v(prev, nxt)).unwrap_or(Vec2::new(1.0, 0.0)); + let out_chord = unit(v(cur, nxt)).unwrap_or(smooth); + let in_chord = unit(v(prev, cur)).unwrap_or(smooth); + let leave = unit(out_chord * (1.0 - round) + smooth * round).unwrap_or(out_chord); + let arrive = unit(in_chord * (1.0 - round) + smooth * round).unwrap_or(in_chord); + (leave, arrive) + }) + .collect(); + let mut path = ShapePath::polygon(simp, true); + for i in 0..n { + let j = (i + 1) % n; + let (a, b) = (idx[i], idx[j]); + let mut pts: Vec = + if b > a { raw[a..=b].iter().map(|q| pt(*q)).collect() } else { raw[a..].iter().chain(raw[..=b].iter()).map(|q| pt(*q)).collect() }; + if pts.len() < 2 { + pts = vec![pt(simp[i]), pt(simp[j])]; + } + let chord = pts[0].distance(pts[pts.len() - 1]); + let (t0, t1) = (dirs[i].0, dirs[j].1); + let (c, _, _) = if pts.len() >= 3 { fit_single(&pts, t0, t1) } else { (kurbo::CubicBez::new(pts[0], pts[0], pts[1], pts[1]), 0.0, 0) }; + // Keep handles pointing along the tangents and within the chord. + let max = chord * 0.5 * round.max(0.34); + let l0 = (c.p1 - c.p0).dot(t0).clamp(0.0, max); + let l1 = (c.p3 - c.p2).dot(t1).clamp(0.0, max); + let (l0, l1) = if pts.len() < 3 { (chord / 3.0 * round, chord / 3.0 * round) } else { (l0, l1) }; + path.out_tangents[i] = [t0.x * l0, t0.y * l0]; + path.in_tangents[j] = [-t1.x * l1, -t1.y * l1]; + } + path +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FillRule, fill_coverage, to_kurbo}; + use effectcraft_geom::Mat3; + + fn iou(a: &[bool], b: &[f32]) -> f64 { + let (mut i, mut u) = (0.0, 0.0); + for (x, y) in a.iter().zip(b) { + let y = *y >= 0.5; + if *x && y { + i += 1.0; + } + if *x || y { + u += 1.0; + } + } + i / u + } + + /// Rasterise traced outlines with even-odd fill (as the Add / Subtract mask stack does). + fn raster(t: &[Traced], w: usize, h: usize) -> Vec { + let paths: Vec<_> = t.iter().map(|t| to_kurbo(&t.path)).collect(); + fill_coverage(&paths, &Mat3::IDENTITY, w as u32, h as u32, FillRule::EvenOdd).data + } + + #[test] + fn traces_a_disc_and_a_square_with_a_hole() { + let (w, h) = (120, 80); + let mut field = vec![0.0f32; w * h]; + let mut truth = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let disc = (fx - 30.0).powi(2) + (fy - 40.0).powi(2) <= 20.0f64.powi(2); + let square = (60.0..110.0).contains(&fx) && (15.0..65.0).contains(&fy); + let hole = (75.0..95.0).contains(&fx) && (30.0..50.0).contains(&fy); + let on = disc || (square && !hole); + truth[y * w + x] = on; + field[y * w + x] = if on { 1.0 } else { 0.0 }; + } + } + for round in [0.0, 0.5, 1.0] { + let t = trace(&field, w, h, &TraceOpts { corner_roundness: round, ..Default::default() }); + assert_eq!(t.len(), 3, "disc, square, hole"); + assert_eq!(t.iter().filter(|t| t.depth == 1).count(), 1, "one hole"); + let r = raster(&t, w, h); + let score = iou(&truth, &r); + // Full roundness rounds the square's corners off as well. + let min = if round >= 1.0 { 0.88 } else { 0.96 }; + assert!(score > min, "IoU {score} at roundness {round}"); + } + // A disc stays a disc when smooth. + let disc: Vec = + (0..w * h).map(|i| if ((i % w) as f64 + 0.5 - 30.0).powi(2) + ((i / w) as f64 + 0.5 - 40.0).powi(2) <= 400.0 { 1.0 } else { 0.0 }).collect(); + let t = trace(&disc, w, h, &TraceOpts { corner_roundness: 1.0, tolerance: 1.5, ..Default::default() }); + let truth_disc: Vec = disc.iter().map(|v| *v > 0.5).collect(); + let score = iou(&truth_disc, &raster(&t, w, h)); + assert!(score > 0.97, "disc IoU {score}"); + assert!(t[0].path.vertices.len() < 40, "simplified to {}", t[0].path.vertices.len()); + // The square traces to 4 corners at zero roundness. + let t = trace(&field, w, h, &TraceOpts { corner_roundness: 0.0, tolerance: 1.0, ..Default::default() }); + let sq = t.iter().find(|t| t.depth == 0 && t.path.vertices.iter().all(|v| v[0] > 55.0)).expect("square"); + assert!(sq.path.vertices.len() <= 8, "{:?}", sq.path.vertices); + } + + #[test] + fn min_area_and_threshold() { + let (w, h) = (40, 40); + let mut field = vec![0.0f32; w * h]; + // A 2×2 speck and a 20×20 block at 0.6. + for y in 2..4 { + for x in 2..4 { + field[y * w + x] = 1.0; + } + } + for y in 10..30 { + for x in 10..30 { + field[y * w + x] = 0.6; + } + } + assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 10.0, ..Default::default() }).len(), 1); + assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 1.0, ..Default::default() }).len(), 2); + // Above 0.6 only the speck remains (and it is too small). + assert!(trace(&field, w, h, &TraceOpts { threshold: 0.7, ..Default::default() }).is_empty()); + // Edge-touching shapes close along the border. + let full = vec![1.0f32; 100]; + let t = trace(&full, 10, 10, &TraceOpts::default()); + assert_eq!(t.len(), 1); + assert!((t[0].area.abs() - 100.0).abs() < 2.0, "{}", t[0].area); + } +} diff --git a/crates/raster/src/cuts.rs b/crates/raster/src/cuts.rs new file mode 100644 index 000000000..28ca93f30 --- /dev/null +++ b/crates/raster/src/cuts.rs @@ -0,0 +1,206 @@ +//! Scene edit (cut) detection (Layer ▸ Scene Edit Detection). +//! +//! Each frame is reduced to a [`Signature`]: a joint RGB colour histogram (4 × 4 × 4 bins, +//! normalised) and a small luma thumbnail. Two consecutive frames are compared with +//! +//! - the **histogram distance** (half the L1 distance, 0…1): large when the colour content +//! changes, insensitive to motion; +//! - the **motion-compensated difference**: block optical flow ([`crate::flow::block_flow`]) +//! from the earlier thumbnail to the later one, the earlier one warped along it, and the mean +//! absolute luma error that remains. Inside a shot motion explains the change and the error +//! stays small; across a cut nothing matches. +//! +//! The dissimilarity is the geometric mean of the two (both must agree, which rejects flashes and +//! fast pans). A frame starts a new shot when its dissimilarity exceeds the absolute threshold +//! and is a clear local peak: several times the median of its neighbours (the adaptive threshold +//! of the classic shot-boundary literature), with a minimum shot length. + +use rayon::prelude::*; + +use crate::Image; +use crate::flow::block_flow; + +const BINS: usize = 4; +const THUMB: u32 = 96; + +/// The per-frame data compared by [`dissimilarity`]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Signature { + pub hist: Vec, + pub thumb: Image, +} + +/// Compute a frame's signature. +pub fn signature(img: &Image) -> Signature { + let mut hist = vec![0f32; BINS * BINS * BINS]; + let mut n = 0.0; + for p in &img.data { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a * a.min(1.0), p[1] / a * a.min(1.0), p[2] / a * a.min(1.0)] } else { [0.0; 3] }; + let b = |v: f32| ((v.clamp(0.0, 1.0) * BINS as f32) as usize).min(BINS - 1); + hist[b(c[0]) * BINS * BINS + b(c[1]) * BINS + b(c[2])] += 1.0; + n += 1.0; + } + if n > 0.0 { + for h in &mut hist { + *h /= n; + } + } + let long = img.width.max(img.height).max(1); + let thumb = if long > THUMB { + let s = THUMB as f64 / long as f64; + crate::resample(img, ((img.width as f64 * s).round() as u32).max(8), ((img.height as f64 * s).round() as u32).max(8)) + } else { + img.clone() + }; + Signature { hist, thumb } +} + +fn luma(p: [f32; 4]) -> f32 { + 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] +} + +/// Dissimilarity of consecutive frames, 0 (same) … 1 (unrelated). +pub fn dissimilarity(a: &Signature, b: &Signature) -> f32 { + let hist: f32 = a.hist.iter().zip(&b.hist).map(|(x, y)| (x - y).abs()).sum::() * 0.5; + let (ta, tb) = (&a.thumb, &b.thumb); + let motion = if ta.width == tb.width && ta.height == tb.height && !ta.is_empty() { + let f = block_flow(tb, ta, 8, 4); + // For each pixel of B, where it came from in A. + let mut err = 0.0f32; + let mut n = 0.0f32; + for y in 0..tb.height { + for x in 0..tb.width { + let v = f.at(x as f64 + 0.5, y as f64 + 0.5); + let pa = ta.sample_bilinear_clamped(x as f64 + 0.5 + v[0] as f64, y as f64 + 0.5 + v[1] as f64); + err += (luma(pa) - luma(tb.data[tb.idx(x, y)])).abs(); + n += 1.0; + } + } + // Mean absolute error of 0.25 (a quarter of full scale) counts as completely different. + (err / n.max(1.0) * 4.0).min(1.0) + } else { + 1.0 + }; + (hist * motion).sqrt() +} + +/// Cut detection options. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CutOpts { + /// Absolute dissimilarity a cut must exceed (0…1). + pub threshold: f32, + /// A cut must be this many times the median of its neighbours. + pub peak_ratio: f32, + /// Neighbours on each side used for the local median. + pub window: usize, + /// Minimum frames between cuts. + pub min_shot: usize, +} + +impl Default for CutOpts { + fn default() -> Self { + CutOpts { threshold: 0.25, peak_ratio: 3.0, window: 4, min_shot: 2 } + } +} + +/// Dissimilarities of every frame to the previous one (`scores[0]` = 0). +pub fn scores(frames: &[Image]) -> Vec { + let sigs: Vec = frames.par_iter().map(signature).collect(); + let mut out = vec![0.0]; + out.extend((1..sigs.len()).into_par_iter().map(|i| dissimilarity(&sigs[i - 1], &sigs[i])).collect::>()); + out +} + +/// Frames that start a new shot (indices into `scores`; never 0). +pub fn detect(scores: &[f32], o: &CutOpts) -> Vec { + let mut cuts: Vec = vec![]; + for i in 1..scores.len() { + let s = scores[i]; + if s < o.threshold { + continue; + } + let lo = i.saturating_sub(o.window).max(1); + let hi = (i + o.window).min(scores.len() - 1); + let mut nb: Vec = (lo..=hi).filter(|&j| j != i).map(|j| scores[j]).collect(); + nb.sort_by(f32::total_cmp); + let med = nb.get(nb.len() / 2).copied().unwrap_or(0.0); + if s < o.peak_ratio * med.max(0.02) { + continue; + } + match cuts.last() { + Some(&last) if i - last < o.min_shot.max(1) => { + // Keep the stronger of two close candidates. + if s > scores[last] { + cuts.pop(); + cuts.push(i); + } + } + _ => cuts.push(i), + } + } + cuts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash_noise; + + /// A textured "shot": `kind` picks the palette and texture, panned by `dx` pixels. + fn frame(kind: u32, dx: f32, w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let fx = x as f32 + dx; + let v = match kind { + 0 => 0.5 + 0.4 * (fx * 0.15).sin() * (y as f32 * 0.11).cos(), + 1 => { + if ((fx / 12.0).floor() as i32 + (y / 12) as i32) % 2 == 0 { + 0.85 + } else { + 0.15 + } + } + _ => hash_noise((fx.floor() as i64 / 4) as u32, y / 4, 7), + }; + let c = match kind { + 0 => [v, v * 0.6, 0.2], + 1 => [0.1, v * 0.8, v], + _ => [v * 0.9, v * 0.9, v * 0.4 + 0.5], + }; + img.set(x, y, [c[0], c[1], c[2], 1.0]); + } + } + img + } + + #[test] + fn finds_synthetic_cuts_exactly() { + // Three shots with camera pans (12, 9 and 11 frames) and a brightness flicker inside the + // second. + let mut frames = vec![]; + for i in 0..12 { + frames.push(frame(0, i as f32 * 2.0, 128, 72)); + } + for i in 0..9 { + let mut f = frame(1, i as f32 * 3.0, 128, 72); + if i == 4 { + f.map_straight(|c| [c[0] * 1.08, c[1] * 1.08, c[2] * 1.08]); + } + frames.push(f); + } + for i in 0..11 { + frames.push(frame(2, -(i as f32) * 4.0, 128, 72)); + } + let s = scores(&frames); + let cuts = detect(&s, &CutOpts::default()); + assert_eq!(cuts, vec![12, 21], "scores {s:?}"); + } + + #[test] + fn a_static_shot_has_no_cuts() { + let frames: Vec = (0..8).map(|i| frame(2, i as f32, 64, 48)).collect(); + assert!(detect(&scores(&frames), &CutOpts::default()).is_empty()); + } +} diff --git a/crates/raster/src/inpaint.rs b/crates/raster/src/inpaint.rs new file mode 100644 index 000000000..a4ae40cec --- /dev/null +++ b/crates/raster/src/inpaint.rs @@ -0,0 +1,958 @@ +//! Content-Aware Fill: filling the transparent area of a layer across a range of frames. +//! +//! Written from the published methods: +//! +//! - **Membrane (harmonic) fill**: the hole takes the smoothest values that match its boundary +//! (Laplace's equation with Dirichlet boundary values), solved coarse to fine with +//! known-weighted pyramids and Jacobi relaxation. Edge Blend uses it directly; it also +//! initialises the other methods and completes flow fields. +//! - **PatchMatch** (Barnes, Shechtman, Finkelstein, Goldman 2009) nearest-neighbour fields +//! inside the **space-time completion** EM loop of Wexler, Shechtman and Irani (2007), applied +//! per frame: every patch overlapping the hole is matched to a fully known patch (random +//! initialisation, propagation, random search); hole pixels become the weighted vote of the +//! overlapping matches; repeated over a multi-scale pyramid, coarse to fine. +//! - **Flow-guided propagation** (the propagation stage of flow-guided video inpainting, e.g. Xu +//! et al. 2019 / Huang et al. 2016): optical flow between neighbouring frames is computed on +//! membrane-filled frames, *completed* inside the hole from the surrounding motion, and each +//! missing pixel follows the flow forwards and backwards until it lands on a frame where that +//! point of the scene is visible; the two candidates are blended by temporal distance. +//! **Lighting Correction** adds the membrane interpolation of the mismatch along the hole's +//! border (Poisson-style seamless blending), scaled by the chosen strength. +//! +//! Methods (After Effects' Content-Aware Fill panel): +//! - **Object**: propagation, then PatchMatch synthesis for what no frame shows; each frame's +//! synthesis starts from the previous frame's result warped along the flow, so the fill stays +//! temporally stable. +//! - **Surface**: propagation, then one synthesised fill carried through the range by the flow +//! (flat, rigid surfaces). +//! - **Edge Blend**: the membrane fill of each frame from its own border (fast; textureless +//! surfaces). +//! +//! Reference frames are complete images of given frames (for example a clean plate): they count as +//! frames where every pixel is visible, so propagation pulls their content into the hole. + +use rayon::prelude::*; + +use crate::Image; +use crate::flow::{Flow, block_flow}; + +/// Fill method. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FillMethod { + #[default] + Object, + Surface, + EdgeBlend, +} + +impl FillMethod { + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "object" => Some(FillMethod::Object), + "surface" => Some(FillMethod::Surface), + "edgeblend" | "edge" => Some(FillMethod::EdgeBlend), + _ => None, + } + } + pub fn name(self) -> &'static str { + match self { + FillMethod::Object => "object", + FillMethod::Surface => "surface", + FillMethod::EdgeBlend => "edgeBlend", + } + } +} + +/// Fill options. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FillOpts { + pub method: FillMethod, + /// Lighting Correction strength 0 (None) … 1 (Strong). + pub lighting: f32, + /// Patch size (odd). + pub patch: usize, + /// Seed of the PatchMatch random search. + pub seed: u32, +} + +impl Default for FillOpts { + fn default() -> Self { + FillOpts { method: FillMethod::Object, lighting: 0.0, patch: 7, seed: 1 } + } +} + +// ------------------------------------------------------------------ helpers + +#[inline] +fn rnd(seed: u32, a: u32, b: u32) -> u32 { + let mut h = seed.wrapping_mul(0x9E37_79B9) ^ a.wrapping_mul(0x85EB_CA6B) ^ b.wrapping_mul(0xC2B2_AE35); + h ^= h >> 16; + h = h.wrapping_mul(0x7FEB_352D); + h ^= h >> 15; + h = h.wrapping_mul(0x846C_A68B); + h ^ (h >> 16) +} + +/// Grow a hole mask by `r` pixels (square structuring element, separable). +pub fn dilate(hole: &[bool], w: usize, h: usize, r: usize) -> Vec { + if r == 0 { + return hole.to_vec(); + } + let mut tmp = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let lo = x.saturating_sub(r); + let hi = (x + r).min(w - 1); + tmp[y * w + x] = (lo..=hi).any(|k| hole[y * w + k]); + } + } + let mut out = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let lo = y.saturating_sub(r); + let hi = (y + r).min(h - 1); + out[y * w + x] = (lo..=hi).any(|k| tmp[k * w + x]); + } + } + out +} + +/// Peak signal-to-noise ratio (dB) of the RGB of `a` against `b` over `region` (all pixels when +/// `None`), for values in 0…1. +pub fn psnr(a: &Image, b: &Image, region: Option<&[bool]>) -> f64 { + let (mut se, mut n) = (0.0f64, 0.0f64); + for i in 0..a.data.len().min(b.data.len()) { + if region.is_some_and(|r| !r[i]) { + continue; + } + for c in 0..3 { + let d = (a.data[i][c] - b.data[i][c]) as f64; + se += d * d; + } + n += 3.0; + } + if se <= 0.0 || n == 0.0 { + return 99.0; + } + 10.0 * (1.0 / (se / n)).log10() +} + +// ------------------------------------------------------------------ membrane + +/// Harmonic fill of `data` (`w × h`, 4 channels) where `known` is false. +fn membrane_core(data: &mut [[f32; 4]], known: &[bool], w: usize, h: usize) { + if !known.iter().any(|k| !*k) { + return; + } + if !known.iter().any(|k| *k) { + return; + } + // Coarse level: known-weighted 2×2 averages. + if w > 8 && h > 8 { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let mut cd = vec![[0.0f32; 4]; cw * ch]; + let mut ck = vec![false; cw * ch]; + for y in 0..ch { + for x in 0..cw { + let mut s = [0.0f32; 4]; + let mut n = 0.0; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let (fx, fy) = (2 * x + dx, 2 * y + dy); + if fx < w && fy < h && known[fy * w + fx] { + let p = data[fy * w + fx]; + for c in 0..4 { + s[c] += p[c]; + } + n += 1.0; + } + } + if n > 0.0 { + cd[y * cw + x] = s.map(|v| v / n); + ck[y * cw + x] = true; + } + } + } + membrane_core(&mut cd, &ck, cw, ch); + for y in 0..h { + for x in 0..w { + if !known[y * w + x] { + data[y * w + x] = cd[(y / 2).min(ch - 1) * cw + (x / 2).min(cw - 1)]; + } + } + } + } + // Jacobi relaxation of the unknown pixels (in place, Gauss–Seidel order). + let iters = if w * h <= 4096 { 200 } else { 40 }; + let unknown: Vec = (0..w * h).filter(|&i| !known[i]).collect(); + for _ in 0..iters { + let mut delta = 0.0f32; + for &i in &unknown { + let (x, y) = (i % w, i / w); + let mut s = [0.0f32; 4]; + let mut n = 0.0; + for (nx, ny) in [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)] { + if nx < w && ny < h { + let p = data[ny * w + nx]; + for c in 0..4 { + s[c] += p[c]; + } + n += 1.0; + } + } + let v = s.map(|v| v / n); + delta = delta.max((v[0] - data[i][0]).abs()); + data[i] = v; + } + if delta < 1e-5 { + break; + } + } +} + +/// Membrane fill: `img` with the `hole` pixels replaced by the harmonic interpolation of their +/// border. +pub fn membrane_fill(img: &Image, hole: &[bool]) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + let mut out = img.clone(); + let known: Vec = hole.iter().map(|h| !*h).collect(); + membrane_core(&mut out.data, &known, w, h); + out +} + +// ------------------------------------------------------------------ PatchMatch + +struct Level { + w: usize, + h: usize, + img: Vec<[f32; 4]>, + hole: Vec, +} + +fn downsample(l: &Level) -> Level { + let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2)); + let mut img = vec![[0.0f32; 4]; w * h]; + let mut hole = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let mut s = [0.0f32; 4]; + let mut n = 0.0; + let mut any_hole = false; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let (fx, fy) = ((2 * x + dx).min(l.w - 1), (2 * y + dy).min(l.h - 1)); + let i = fy * l.w + fx; + if l.hole[i] { + any_hole = true; + } else { + for c in 0..4 { + s[c] += l.img[i][c]; + } + n += 1.0; + } + } + hole[y * w + x] = any_hole; + if n > 0.0 { + img[y * w + x] = s.map(|v| v / n); + } + } + } + Level { w, h, img, hole } +} + +/// Summed-area table of hole pixels, for "is this patch fully known" queries. +struct HoleSat { + w: usize, + s: Vec, +} + +impl HoleSat { + fn new(hole: &[bool], w: usize, h: usize) -> HoleSat { + let sw = w + 1; + let mut s = vec![0u32; sw * (h + 1)]; + for y in 0..h { + for x in 0..w { + s[(y + 1) * sw + x + 1] = hole[y * w + x] as u32 + s[y * sw + x + 1] + s[(y + 1) * sw + x] - s[y * sw + x]; + } + } + HoleSat { w: sw, s } + } + /// Hole pixels in [x0, x1) × [y0, y1). + fn count(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> u32 { + self.s[y1 * self.w + x1] + self.s[y0 * self.w + x0] - self.s[y0 * self.w + x1] - self.s[y1 * self.w + x0] + } +} + +struct Pm<'a> { + w: usize, + h: usize, + r: usize, + img: &'a [[f32; 4]], + sat: HoleSat, +} + +impl Pm<'_> { + fn valid_source(&self, x: i64, y: i64) -> bool { + let r = self.r as i64; + x >= r + && y >= r + && x + r < self.w as i64 + && y + r < self.h as i64 + && self.sat.count((x - r) as usize, (y - r) as usize, (x + r + 1) as usize, (y + r + 1) as usize) == 0 + } + /// SSD between the patch at target (tx, ty) (clipped to the image) and source (sx, sy). + fn dist(&self, tx: i64, ty: i64, sx: i64, sy: i64, cap: f32) -> f32 { + let r = self.r as i64; + let mut d = 0.0f32; + let mut n = 0.0f32; + for dy in -r..=r { + let (ty2, sy2) = (ty + dy, sy + dy); + if ty2 < 0 || ty2 >= self.h as i64 { + continue; + } + for dx in -r..=r { + let tx2 = tx + dx; + if tx2 < 0 || tx2 >= self.w as i64 { + continue; + } + let a = self.img[ty2 as usize * self.w + tx2 as usize]; + let b = self.img[sy2 as usize * self.w + (sx + dx) as usize]; + for c in 0..4 { + let e = a[c] - b[c]; + d += e * e; + } + n += 1.0; + } + if d > cap * n.max(1.0) { + return f32::INFINITY; + } + } + d / n.max(1.0) + } +} + +/// One Wexler/PatchMatch level: refine `img` (hole pixels) with `em` EM rounds, starting from the +/// nearest-neighbour field `nnf` (per pixel, source centre; `None` = random). +fn pm_level(l: &mut Level, nnf: &mut Vec>, em: usize, pm_iters: usize, patch: usize, seed: u32) { + let (w, h) = (l.w, l.h); + let r = (patch / 2).min((w.min(h).saturating_sub(1)) / 2).max(1); + // Target centres: pixels whose patch touches the hole. + let near = dilate(&l.hole, w, h, r); + let targets: Vec = (0..w * h).filter(|&i| near[i]).collect(); + if targets.is_empty() { + return; + } + let sat = HoleSat::new(&l.hole, w, h); + // Sources: centres of fully known patches. + let sources: Vec<[i32; 2]> = { + let pm = Pm { w, h, r, img: &l.img, sat: HoleSat::new(&l.hole, w, h) }; + (0..w * h).filter(|&i| pm.valid_source((i % w) as i64, (i / w) as i64)).map(|i| [(i % w) as i32, (i / w) as i32]).collect() + }; + if sources.is_empty() { + return; + } + nnf.resize(w * h, None); + for e in 0..em { + let pm = Pm { w, h, r, img: &l.img, sat: HoleSat { w: sat.w, s: sat.s.clone() } }; + // Initialise / validate. + let mut field: Vec<([i32; 2], f32)> = vec![([0, 0], f32::INFINITY); w * h]; + targets + .par_iter() + .map(|&i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let s = nnf[i] + .filter(|s| pm.valid_source(s[0] as i64, s[1] as i64)) + .unwrap_or_else(|| sources[rnd(seed ^ e as u32, i as u32, 0) as usize % sources.len()]); + (i, (s, pm.dist(x, y, s[0] as i64, s[1] as i64, f32::INFINITY))) + }) + .collect::>() + .into_iter() + .for_each(|(i, v)| field[i] = v); + let is_target = &near; + for it in 0..pm_iters { + let fwd = it % 2 == 0; + let snap = field.clone(); + let rows: Vec> = (0..h) + .into_par_iter() + .map(|y| { + let mut row: Vec<([i32; 2], f32)> = snap[y * w..(y + 1) * w].to_vec(); + let mut out = vec![]; + let xs: Box> = if fwd { Box::new(0..w) } else { Box::new((0..w).rev()) }; + for x in xs { + let i = y * w + x; + if !is_target[i] { + continue; + } + let (mut best, mut bd) = row[x]; + let (xi, yi) = (x as i64, y as i64); + let try_c = |c: [i32; 2], best: &mut [i32; 2], bd: &mut f32| { + if c != *best && pm.valid_source(c[0] as i64, c[1] as i64) { + let d = pm.dist(xi, yi, c[0] as i64, c[1] as i64, *bd); + if d < *bd { + *bd = d; + *best = c; + } + } + }; + // Propagation from the scan-order neighbours. + let step: i64 = if fwd { -1 } else { 1 }; + let nx = xi + step; + if nx >= 0 && nx < w as i64 && is_target[y * w + nx as usize] { + let s = row[nx as usize].0; + try_c([s[0] - step as i32, s[1]], &mut best, &mut bd); + } + let ny = yi + step; + if ny >= 0 && ny < h as i64 && is_target[ny as usize * w + x] { + let s = snap[ny as usize * w + x].0; + try_c([s[0], s[1] - step as i32], &mut best, &mut bd); + } + // Random search around the best match, halving the radius. + let mut rad = w.max(h) as i64; + let mut k = 0u32; + while rad >= 1 { + let a = rnd(seed.wrapping_add(it as u32 * 977 + e as u32 * 31), i as u32, k); + let ox = (a % (2 * rad as u32 + 1)) as i64 - rad; + let oy = ((a >> 16) % (2 * rad as u32 + 1)) as i64 - rad; + try_c([(best[0] as i64 + ox) as i32, (best[1] as i64 + oy) as i32], &mut best, &mut bd); + rad /= 2; + k += 1; + } + // And a random source anywhere (helps escape bad starts). + let any = sources[rnd(seed ^ 0xABCD, i as u32, it as u32 + e as u32 * 64) as usize % sources.len()]; + try_c(any, &mut best, &mut bd); + row[x] = (best, bd); + out.push((i, (best, bd))); + } + out + }) + .collect(); + for row in rows { + for (i, v) in row { + field[i] = v; + } + } + } + // Vote. + let mut ds: Vec = targets.iter().map(|&i| field[i].1).filter(|d| d.is_finite()).collect(); + ds.sort_by(f32::total_cmp); + let sigma2 = ds.get(ds.len() * 3 / 4).copied().unwrap_or(1.0).max(1e-5); + let ri = r as i64; + let new: Vec<(usize, [f32; 4])> = (0..w * h) + .into_par_iter() + .filter(|&i| l.hole[i]) + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let mut acc = [0.0f32; 4]; + let mut wsum = 0.0f32; + for dy in -ri..=ri { + for dx in -ri..=ri { + let (px, py) = (x + dx, y + dy); + if px < 0 || py < 0 || px >= w as i64 || py >= h as i64 { + continue; + } + let (s, d) = field[py as usize * w + px as usize]; + if !d.is_finite() { + continue; + } + let (sx, sy) = (s[0] as i64 - dx, s[1] as i64 - dy); + let wt = (-d / (2.0 * sigma2)).exp(); + let v = l.img[sy as usize * w + sx as usize]; + for c in 0..4 { + acc[c] += v[c] * wt; + } + wsum += wt; + } + } + (i, if wsum > 0.0 { acc.map(|v| v / wsum) } else { l.img[i] }) + }) + .collect(); + for (i, v) in new { + l.img[i] = v; + } + for &i in &targets { + nnf[i] = Some(field[i].0); + } + } +} + +/// Fill the `hole` of `img` with PatchMatch synthesis (multi-scale Wexler EM). `init`: a starting +/// guess for the hole (e.g. the previous frame's fill warped here); with it only the finest +/// scale is refined. +pub fn patch_fill(img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + if !hole.iter().any(|b| *b) || w == 0 || h == 0 { + return img.clone(); + } + let patch = patch.max(3) | 1; + let fine = Level { w, h, img: img.data.clone(), hole: hole.to_vec() }; + if let Some(init) = init { + let mut l = fine; + for (i, v) in l.img.iter_mut().enumerate() { + if l.hole[i] { + *v = init.data[i]; + } + } + let mut nnf = vec![]; + pm_level(&mut l, &mut nnf, 2, 4, patch, seed); + return Image { width: img.width, height: img.height, data: l.img }; + } + // Pyramid: stop while the image is still a few patches across. + let mut levels = vec![fine]; + while levels.len() < 6 { + let last = levels.last().expect("level"); + if last.w.min(last.h) / 2 < patch * 4 || !last.hole.iter().any(|b| *b) { + break; + } + let d = downsample(last); + // Keep at least one fully known patch. + if !d.hole.iter().any(|b| !*b) { + break; + } + levels.push(d); + } + // Coarsest: membrane start. + let top = levels.len() - 1; + { + let l = &mut levels[top]; + let known: Vec = l.hole.iter().map(|b| !*b).collect(); + membrane_core(&mut l.img, &known, l.w, l.h); + } + let mut nnf: Vec> = vec![]; + for k in (0..=top).rev() { + if k < top { + // Upsample the coarser result into this level's hole and the NNF (×2). + let (coarse_img, cw, ch, coarse_nnf) = { + let c = &levels[k + 1]; + (c.img.clone(), c.w, c.h, nnf.clone()) + }; + let l = &mut levels[k]; + for y in 0..l.h { + for x in 0..l.w { + let i = y * l.w + x; + if l.hole[i] { + // Bilinear sample of the coarse image. + let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let g = |xx: usize, yy: usize| coarse_img[yy * cw + xx]; + let mut v = [0.0f32; 4]; + for c in 0..4 { + let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; + let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; + v[c] = top + (bot - top) * ty; + } + l.img[i] = v; + } + } + } + nnf = (0..l.w * l.h) + .map(|i| { + let (x, y) = (i % l.w, i / l.w); + let ci = (y / 2).min(ch - 1) * cw + (x / 2).min(cw - 1); + coarse_nnf.get(ci).copied().flatten().map(|s| [s[0] * 2 + (x % 2) as i32, s[1] * 2 + (y % 2) as i32]) + }) + .collect(); + } + let em = if k == top { 8 } else { 4 }; + pm_level(&mut levels[k], &mut nnf, em, 5, patch, seed.wrapping_add(k as u32 * 101)); + } + let l = levels.swap_remove(0); + Image { width: img.width, height: img.height, data: l.img } +} + +// ------------------------------------------------------------------ flow-guided propagation + +/// Block flow from `a` to `b` with the tiles that touch either hole replaced by the membrane +/// interpolation of the surrounding motion (flow completion). +fn completed_flow(a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow { + let mut f = block_flow(a, b, 8, 6); + if f.v.is_empty() { + return f; + } + let (w, bs) = (a.width as usize, f.block as usize); + let mut known = vec![true; f.v.len()]; + for (i, k) in known.iter_mut().enumerate() { + let (c, r) = (i % f.cols, i / f.cols); + // A tile is unreliable when any pixel within it (and a block's margin) is a hole. + let (x0, y0) = ((c * bs).saturating_sub(bs / 2), (r * bs).saturating_sub(bs / 2)); + let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(a.height as usize)); + 'tile: for y in y0..y1 { + for x in x0..x1 { + if hole_a[y * w + x] || hole_b[y * w + x] { + *k = false; + break 'tile; + } + } + } + } + if known.iter().all(|k| !*k) { + f.v.iter_mut().for_each(|v| *v = [0.0; 2]); + return f; + } + let mut d: Vec<[f32; 4]> = f.v.iter().map(|v| [v[0], v[1], 0.0, 0.0]).collect(); + membrane_core(&mut d, &known, f.cols, f.rows); + for (v, x) in f.v.iter_mut().zip(d) { + *v = [x[0], x[1]]; + } + f +} + +/// The input of a sequence fill. +pub struct FillInput { + /// Frames (premultiplied RGBA, all the same size). + pub frames: Vec, + /// Per frame, `true` where the pixel is missing. + pub holes: Vec>, + /// Complete images for some frames: (frame index, image). + pub references: Vec<(usize, Image)>, +} + +/// Trace pixel (x, y) of frame `t` through `flows` (`step` = +1 forward with `fwd`, −1 backward +/// with `bwd`) to the nearest frame where it is visible: (sampled colour, frames travelled). +fn trace( + t: usize, + x: f64, + y: f64, + step: i64, + fwd: &[Flow], + bwd: &[Flow], + known: &dyn Fn(usize, i64, i64) -> bool, + frames: &[&Image], + max: usize, +) -> Option<([f32; 4], usize)> { + let n = frames.len(); + let (w, h) = (frames[0].width as f64, frames[0].height as f64); + let (mut px, mut py) = (x, y); + let mut k = t as i64; + for d in 1..=max { + let v = if step > 0 { + if k as usize + 1 >= n { + return None; + } + fwd[k as usize].at(px, py) + } else { + if k == 0 { + return None; + } + bwd[k as usize - 1].at(px, py) + }; + px += v[0] as f64; + py += v[1] as f64; + k += step; + if px < 0.0 || py < 0.0 || px >= w || py >= h { + return None; + } + // Visible when the bilinear support is known. + let (x0, y0) = ((px - 0.5).floor() as i64, (py - 0.5).floor() as i64); + let ok = [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)] + .iter() + .all(|&(xx, yy)| known(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1))); + if ok { + return Some((frames[k as usize].sample_bilinear_clamped(px, py), d)); + } + } + None +} + +/// Fill a range of frames. `progress(done, total)` returns `false` to cancel (→ `None`). +pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option> { + let n = input.frames.len(); + if n == 0 { + return Some(vec![]); + } + let (w, h) = (input.frames[0].width as usize, input.frames[0].height as usize); + // Steps: flows (1) + propagation (1) + per-frame synthesis (n). + let total = n + 2; + if o.method == FillMethod::EdgeBlend { + let mut out = Vec::with_capacity(n); + for (i, (f, hl)) in input.frames.iter().zip(&input.holes).enumerate() { + out.push(opaque_fill(membrane_fill(f, hl), hl)); + if !progress(i + 1, n) { + return None; + } + } + return Some(out); + } + // Reference frames make their frame fully known. + let mut frames: Vec = input.frames.clone(); + let mut holes: Vec> = input.holes.clone(); + let mut is_ref = vec![false; n]; + for (k, img) in &input.references { + if *k < n && img.width as usize == w && img.height as usize == h { + frames[*k] = img.clone(); + holes[*k] = vec![false; w * h]; + is_ref[*k] = true; + } + } + // 1. Flows between membrane-filled neighbours, completed inside the holes. + let pre: Vec = frames.par_iter().zip(&holes).map(|(f, hl)| membrane_fill(f, hl)).collect(); + let pairs: Vec<(Flow, Flow)> = (0..n.saturating_sub(1)) + .into_par_iter() + .map(|i| (completed_flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), completed_flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i]))) + .collect(); + let (fwd, bwd): (Vec, Vec) = pairs.into_iter().unzip(); + if !progress(1, total) { + return None; + } + // 2. Propagation (and the lighting-correction border ring). + let refs: Vec<&Image> = frames.iter().collect(); + let known = |k: usize, x: i64, y: i64| !holes[k][y as usize * w + x as usize]; + let ring_r = 2usize; + let propagated: Vec<(Image, Vec)> = (0..n) + .into_par_iter() + .map(|t| { + let mut img = frames[t].clone(); + let mut left = holes[t].clone(); + if is_ref[t] || !holes[t].iter().any(|b| *b) { + return (img, left); + } + let sample = |i: usize| -> Option<[f32; 4]> { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let f = trace(t, x, y, 1, &fwd, &bwd, &known, &refs, n); + let b = trace(t, x, y, -1, &fwd, &bwd, &known, &refs, n); + match (f, b) { + (Some((a, da)), Some((c, dc))) => { + let (wa, wc) = (1.0 / da as f32, 1.0 / dc as f32); + Some(std::array::from_fn(|k| (a[k] * wa + c[k] * wc) / (wa + wc))) + } + (Some((a, _)), None) | (None, Some((a, _))) => Some(a), + _ => None, + } + }; + let filled: Vec<(usize, [f32; 4])> = (0..w * h).filter(|&i| holes[t][i]).filter_map(|i| sample(i).map(|v| (i, v))).collect(); + for (i, v) in &filled { + img.data[*i] = *v; + left[*i] = false; + } + // Lighting correction: membrane of the border mismatch, added to propagated pixels. + if o.lighting > 0.0 && !filled.is_empty() { + let grown = dilate(&holes[t], w, h, ring_r); + let mut diff = vec![[0.0f32; 4]; w * h]; + let mut dk = vec![true; w * h]; + for i in 0..w * h { + if holes[t][i] { + dk[i] = false; + } else if grown[i] + && let Some(v) = sample(i) + { + let a = frames[t].data[i]; + diff[i] = std::array::from_fn(|c| if c < 3 { a[c] - v[c] } else { 0.0 }); + } + } + membrane_core(&mut diff, &dk, w, h); + for (i, _) in &filled { + for c in 0..3 { + img.data[*i][c] += o.lighting * diff[*i][c]; + } + } + } + (img, left) + }) + .collect(); + if !progress(2, total) { + return None; + } + // 3. Synthesis of what no frame shows. + let mut out: Vec> = vec![None; n]; + let first = (0..n).find(|&t| propagated[t].1.iter().any(|b| *b)); + let Some(t0) = first else { + return Some(propagated.into_iter().zip(&holes).map(|((img, _), hl)| opaque_fill(img, hl)).collect()); + }; + // Frames before t0 are complete already. + for (t, slot) in out.iter_mut().enumerate().take(t0) { + *slot = Some(propagated[t].0.clone()); + } + let mut done = 2; + out[t0] = Some(patch_fill(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed)); + done += 1; + if !progress(done, total) { + return None; + } + for t in t0 + 1..n { + let (img, left) = &propagated[t]; + if !left.iter().any(|b| *b) { + out[t] = Some(img.clone()); + } else { + // Warp the previous result here along the backward flow (t → t − 1). + let prev = out[t - 1].as_ref().expect("previous frame"); + let mut warped = img.clone(); + for i in 0..w * h { + if left[i] { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let v = bwd[t - 1].at(x, y); + warped.data[i] = prev.sample_bilinear_clamped(x + v[0] as f64, y + v[1] as f64); + } + } + out[t] = Some(match o.method { + FillMethod::Surface => warped, + _ => patch_fill(img, left, Some(&warped), o.patch, o.seed), + }); + } + done += 1; + if !progress(done, total) { + return None; + } + } + Some(out.into_iter().zip(&holes).map(|(img, hl)| opaque_fill(img.unwrap_or_default(), hl)).collect()) +} + +/// Filled pixels are opaque. +fn opaque_fill(mut img: Image, hole: &[bool]) -> Image { + for (p, hl) in img.data.iter_mut().zip(hole) { + if *hl && p[3] > 1e-6 { + let a = p[3]; + *p = [p[0] / a, p[1] / a, p[2] / a, 1.0]; + } + } + img +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash_noise; + + /// A textured background (periodic stripes + blocky noise), shifted by `dx`. + fn background(w: u32, h: u32, dx: f32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let fx = x as f32 + dx; + let s = 0.5 + 0.3 * (fx * std::f32::consts::TAU / 10.0).sin(); + let n = hash_noise(((fx.floor() as i64).rem_euclid(20) / 4) as u32, (y % 20) / 4, 3); + img.set(x, y, [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]); + } + } + img + } + + fn cut(img: &Image, hole: &[bool]) -> Image { + let mut o = img.clone(); + for (p, h) in o.data.iter_mut().zip(hole) { + if *h { + *p = [0.0; 4]; + } + } + o + } + + fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec { + (0..w * h).map(|i| (x0..x0 + s).contains(&(i % w)) && (y0..y0 + s).contains(&(i / w))).collect() + } + + #[test] + fn membrane_matches_a_linear_ramp() { + let (w, h) = (40u32, 30u32); + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let v = x as f32 / w as f32; + img.set(x, y, [v, v, v, 1.0]); + } + } + let hole = rect_hole(w, h, 10, 8, 14); + let f = membrane_fill(&cut(&img, &hole), &hole); + assert!(psnr(&f, &img, Some(&hole)) > 40.0, "{}", psnr(&f, &img, Some(&hole))); + } + + #[test] + fn patchmatch_beats_the_membrane_on_texture() { + let (w, h) = (96u32, 72u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + let mem = membrane_fill(&src, &hole); + let pm = patch_fill(&src, &hole, None, 7, 1); + let (pm_db, mem_db) = (psnr(&pm, >, Some(&hole)), psnr(&mem, >, Some(&hole))); + assert!(pm_db > mem_db + 3.0 && pm_db > 20.0, "PatchMatch {pm_db:.1} dB vs membrane {mem_db:.1} dB"); + // Known pixels are untouched. + for i in 0..hole.len() { + if !hole[i] { + assert_eq!(pm.data[i], gt.data[i]); + } + } + } + + /// A moving object removed from a (static or panning) textured background. + fn sequence(n: usize, pan: f32) -> (FillInput, Vec) { + let (w, h) = (96u32, 64u32); + let mut frames = vec![]; + let mut holes = vec![]; + let mut gts = vec![]; + for t in 0..n { + let gt = background(w, h, t as f32 * pan); + let hole = rect_hole(w, h, 10 + 8 * t as u32, 22, 18); + frames.push(cut(>, &hole)); + holes.push(hole); + gts.push(gt); + } + (FillInput { frames, holes, references: vec![] }, gts) + } + + #[test] + fn object_fill_recovers_background_and_is_stable() { + for pan in [0.0, 1.0] { + let (input, gts) = sequence(8, pan); + let out = fill_sequence(&input, &FillOpts::default(), &mut |_, _| true).expect("fill"); + for t in 0..out.len() { + let db = psnr(&out[t], >s[t], Some(&input.holes[t])); + assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB"); + assert_eq!(out[t].data[0][3], 1.0); + } + if pan == 0.0 { + // Temporal consistency: on a static background the filled pixels agree between + // frames. + for t in 1..out.len() { + let both: Vec = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect(); + let db = psnr(&out[t], &out[t - 1], Some(&both)); + assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1); + } + } + } + } + + #[test] + fn surface_and_edge_blend_and_references() { + // A hole that never moves: nothing to propagate, so synthesis must carry it. + let (w, h) = (80u32, 60u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 30, 20, 14); + let input = FillInput { frames: vec![cut(>, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] }; + let surf = fill_sequence(&input, &FillOpts { method: FillMethod::Surface, ..Default::default() }, &mut |_, _| true).expect("fill"); + for t in 1..4 { + // Surface carries one fill through: identical frames. + assert!(psnr(&surf[t], &surf[0], Some(&hole)) > 40.0); + } + let edge = fill_sequence(&input, &FillOpts { method: FillMethod::EdgeBlend, ..Default::default() }, &mut |_, _| true).expect("fill"); + assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0); + // A clean reference frame recovers the background exactly. + let mut with_ref = FillInput { frames: input.frames.clone(), holes: input.holes.clone(), references: vec![(2, gt.clone())] }; + let out = fill_sequence(&with_ref, &FillOpts::default(), &mut |_, _| true).expect("fill"); + for (t, f) in out.iter().enumerate() { + assert!(psnr(f, >, Some(&hole)) > 35.0, "frame {t}: {:.1}", psnr(f, >, Some(&hole))); + } + // Cancel. + with_ref.references.clear(); + assert!(fill_sequence(&with_ref, &FillOpts::default(), &mut |d, _| d < 2).is_none()); + } + + #[test] + fn lighting_correction_absorbs_a_brightness_change() { + // The background brightens over time; propagated pixels come from darker frames. + let (mut input, mut gts) = sequence(6, 0.0); + for t in 0..6 { + let k = 1.0 + 0.06 * t as f32; + for img in [&mut input.frames[t], &mut gts[t]] { + for p in &mut img.data { + for c in 0..3 { + p[c] *= k; + } + } + } + } + let plain = fill_sequence(&input, &FillOpts::default(), &mut |_, _| true).expect("fill"); + let lit = fill_sequence(&input, &FillOpts { lighting: 1.0, ..Default::default() }, &mut |_, _| true).expect("fill"); + let score = |o: &[Image]| (0..6).map(|t| psnr(&o[t], >s[t], Some(&input.holes[t]))).sum::(); + assert!(score(&lit) > score(&plain), "lit {} vs plain {}", score(&lit), score(&plain)); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 4f2441996..f3e64695e 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -5,7 +5,10 @@ pub mod blur; pub mod channels3d; +pub mod cuts; pub mod flow; +pub mod inpaint; +pub mod scopes; pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; diff --git a/crates/raster/src/scopes.rs b/crates/raster/src/scopes.rs new file mode 100644 index 000000000..befb26ef3 --- /dev/null +++ b/crates/raster/src/scopes.rs @@ -0,0 +1,552 @@ +//! Video scopes (the Lumetri Scopes panel): waveform (RGB, Luma, YC), vectorscope (YUV, HLS), +//! histogram and parade (RGB, YUV), computed from a frame as density plots. +//! +//! The maths is the textbook broadcast definition: luma `Y = Kr·R + Kg·G + Kb·B` with the +//! ITU-R BT.601 / BT.709 / BT.2020 coefficients, colour differences `Cb = (B − Y) / (2(1 − Kb))` +//! and `Cr = (R − Y) / (2(1 − Kr))` (each in −0.5…0.5). Values are read straight (un-premultiplied) +//! from the frame. Every scope is a [`Scope`]: a `width × height` grid of three density planes +//! (one per trace colour) normalised so the busiest cell is 1, ready to be tinted and drawn. +//! +//! Scales: **8-bit** maps 0…1 to the plot (0…255 graticule); **float** maps −0.25…1.25 so +//! super-white and sub-black values stay visible. **Clamp** limits the signal to 0…1 first. + +use rayon::prelude::*; + +use crate::Image; + +/// Luma / colour-difference coefficients. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorStandard { + Rec601, + #[default] + Rec709, + Rec2020, +} + +impl ColorStandard { + pub const ALL: [ColorStandard; 3] = [ColorStandard::Rec601, ColorStandard::Rec709, ColorStandard::Rec2020]; + /// (Kr, Kb). + pub fn coefficients(self) -> (f32, f32) { + match self { + ColorStandard::Rec601 => (0.299, 0.114), + ColorStandard::Rec709 => (0.2126, 0.0722), + ColorStandard::Rec2020 => (0.2627, 0.0593), + } + } + pub fn name(self) -> &'static str { + match self { + ColorStandard::Rec601 => "rec601", + ColorStandard::Rec709 => "rec709", + ColorStandard::Rec2020 => "rec2020", + } + } + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '.', '-', '_'], "").as_str() { + "rec601" | "601" | "bt601" => Some(ColorStandard::Rec601), + "rec709" | "709" | "bt709" => Some(ColorStandard::Rec709), + "rec2020" | "2020" | "bt2020" => Some(ColorStandard::Rec2020), + _ => None, + } + } + /// (Y, Cb, Cr) of a straight RGB colour. + pub fn ycbcr(self, rgb: [f32; 3]) -> [f32; 3] { + let (kr, kb) = self.coefficients(); + let y = kr * rgb[0] + (1.0 - kr - kb) * rgb[1] + kb * rgb[2]; + [y, (rgb[2] - y) / (2.0 * (1.0 - kb)), (rgb[0] - y) / (2.0 * (1.0 - kr))] + } +} + +/// Which scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeKind { + WaveformRgb, + WaveformLuma, + /// Luma (plane 0) and chroma amplitude `√(Cb² + Cr²)` (plane 2). + WaveformYc, + /// Cb across, Cr up. + VectorscopeYuv, + /// Hue around the circle, saturation outwards (HLS). + VectorscopeHls, + /// R, G, B histograms (bins across, counts up). + Histogram, + ParadeRgb, + ParadeYuv, +} + +impl ScopeKind { + pub const ALL: [ScopeKind; 8] = [ + ScopeKind::WaveformRgb, + ScopeKind::WaveformLuma, + ScopeKind::WaveformYc, + ScopeKind::VectorscopeYuv, + ScopeKind::VectorscopeHls, + ScopeKind::Histogram, + ScopeKind::ParadeRgb, + ScopeKind::ParadeYuv, + ]; + pub fn name(self) -> &'static str { + match self { + ScopeKind::WaveformRgb => "waveformRgb", + ScopeKind::WaveformLuma => "waveformLuma", + ScopeKind::WaveformYc => "waveformYc", + ScopeKind::VectorscopeYuv => "vectorscopeYuv", + ScopeKind::VectorscopeHls => "vectorscopeHls", + ScopeKind::Histogram => "histogram", + ScopeKind::ParadeRgb => "paradeRgb", + ScopeKind::ParadeYuv => "paradeYuv", + } + } + pub fn label(self) -> &'static str { + match self { + ScopeKind::WaveformRgb => "Waveform (RGB)", + ScopeKind::WaveformLuma => "Waveform (Luma)", + ScopeKind::WaveformYc => "Waveform (YC)", + ScopeKind::VectorscopeYuv => "Vectorscope YUV", + ScopeKind::VectorscopeHls => "Vectorscope HLS", + ScopeKind::Histogram => "Histogram", + ScopeKind::ParadeRgb => "Parade (RGB)", + ScopeKind::ParadeYuv => "Parade (YUV)", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '(', ')', '_', '-'], ""); + Self::ALL.iter().copied().find(|k| k.name().to_ascii_lowercase() == n || k.label().to_ascii_lowercase().replace([' ', '(', ')'], "") == n) + } + /// Vectorscopes are square. + pub fn is_vectorscope(self) -> bool { + matches!(self, ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls) + } +} + +/// Scope options. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScopeOpts { + pub standard: ColorStandard, + /// Float scale (−0.25…1.25) instead of 8-bit (0…1). + pub float: bool, + /// Clamp the signal to 0…1 before plotting. + pub clamp: bool, + /// Plot size. + pub width: u32, + pub height: u32, +} + +impl Default for ScopeOpts { + fn default() -> Self { + ScopeOpts { standard: ColorStandard::Rec709, float: false, clamp: true, width: 256, height: 256 } + } +} + +impl ScopeOpts { + /// Signal range shown on the value axis. + pub fn range(&self) -> (f32, f32) { + if self.float { (-0.25, 1.25) } else { (0.0, 1.0) } + } + /// Plot row (0 = top) of a value, or `None` when off-scale. + fn row(&self, v: f32) -> Option { + let (lo, hi) = self.range(); + let f = (v - lo) / (hi - lo); + if !(0.0..=1.0).contains(&f) { + return None; + } + let r = ((1.0 - f) * (self.height - 1) as f32).round() as usize; + Some(r.min(self.height as usize - 1)) + } +} + +/// A computed scope: three density planes (`planes[c][y * width + x]`, 0…1). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Scope { + pub width: u32, + pub height: u32, + pub planes: [Vec; 3], + /// Raw counts before normalisation (same layout), for statistics and tests. + pub counts: [Vec; 3], +} + +impl Scope { + fn new(w: u32, h: u32) -> Scope { + let n = w as usize * h as usize; + Scope { width: w, height: h, planes: [vec![0.0; n], vec![0.0; n], vec![0.0; n]], counts: [vec![0; n], vec![0; n], vec![0; n]] } + } + fn merge(mut self, o: Scope) -> Scope { + for c in 0..3 { + for (a, b) in self.counts[c].iter_mut().zip(&o.counts[c]) { + *a += *b; + } + } + self + } + fn normalise(&mut self) { + // Square-root density so faint traces stay visible next to dense ones. + let max = self.counts.iter().flat_map(|p| p.iter()).copied().max().unwrap_or(0).max(1) as f32; + for c in 0..3 { + self.planes[c] = self.counts[c].iter().map(|&n| (n as f32 / max).sqrt()).collect(); + } + } + /// Total count of plane `c`. + pub fn total(&self, c: usize) -> u64 { + self.counts[c].iter().map(|&n| n as u64).sum() + } + /// The cell of plane `c` with the most samples, as (x, y). + pub fn peak(&self, c: usize) -> (u32, u32) { + let (i, _) = self.counts[c].iter().enumerate().max_by_key(|(_, n)| **n).unwrap_or((0, &0)); + (i as u32 % self.width.max(1), i as u32 / self.width.max(1)) + } + /// Straight RGBA8 picture: plane 0 red, 1 green, 2 blue (`mono`: all planes white). + pub fn to_rgba8(&self, mono: bool) -> Vec { + let n = self.width as usize * self.height as usize; + let mut out = vec![0u8; n * 4]; + for i in 0..n { + let (r, g, b) = (self.planes[0][i], self.planes[1][i], self.planes[2][i]); + let (r, g, b) = if mono { + let m = r.max(g).max(b); + (m, m, m) + } else { + (r, g, b) + }; + let a = r.max(g).max(b); + out[i * 4] = (r.min(1.0) * 255.0) as u8; + out[i * 4 + 1] = (g.min(1.0) * 255.0) as u8; + out[i * 4 + 2] = (b.min(1.0) * 255.0) as u8; + out[i * 4 + 3] = (a.min(1.0) * 255.0) as u8; + } + out + } +} + +/// Straight RGB of pixel `i` (transparent pixels read as black), clamped when asked. +#[inline] +fn rgb(img: &Image, i: usize, clamp: bool) -> [f32; 3] { + let p = img.data[i]; + let mut c = if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] }; + // Composite over black, as a monitor shows the frame. + let a = p[3].clamp(0.0, 1.0); + for v in &mut c { + *v *= a; + if clamp { + *v = v.clamp(0.0, 1.0); + } + } + c +} + +/// Compute a scope of `img`. +pub fn compute(img: &Image, kind: ScopeKind, o: &ScopeOpts) -> Scope { + let (w, h) = (o.width.max(8), o.height.max(8)); + let o = ScopeOpts { width: w, height: h, ..*o }; + if img.is_empty() { + return Scope::new(w, h); + } + let iw = img.width as usize; + let rows: Vec = (0..img.height as usize).collect(); + let mut s = rows + .par_chunks(16) + .map(|chunk| { + let mut s = Scope::new(w, h); + for &y in chunk { + for x in 0..iw { + plot(&mut s, kind, &o, rgb(img, y * iw + x, o.clamp), x as f32 / iw as f32); + } + } + s + }) + .reduce(|| Scope::new(w, h), Scope::merge); + s.normalise(); + s +} + +#[inline] +fn bump(s: &mut Scope, c: usize, x: usize, y: usize) { + let i = y * s.width as usize + x.min(s.width as usize - 1); + s.counts[c][i] += 1; +} + +/// Add one pixel (straight RGB, at horizontal position `fx` 0…1 in the frame). +fn plot(s: &mut Scope, kind: ScopeKind, o: &ScopeOpts, c: [f32; 3], fx: f32) { + let w = s.width as usize; + let col = |fx: f32, x0: usize, span: usize| x0 + ((fx * span as f32) as usize).min(span - 1); + match kind { + ScopeKind::WaveformRgb => { + let x = col(fx, 0, w); + for (ch, v) in c.iter().enumerate() { + if let Some(y) = o.row(*v) { + bump(s, ch, x, y); + } + } + } + ScopeKind::WaveformLuma => { + let x = col(fx, 0, w); + let yv = o.standard.ycbcr(c)[0]; + if let Some(y) = o.row(yv) { + for ch in 0..3 { + bump(s, ch, x, y); + } + } + } + ScopeKind::WaveformYc => { + let x = col(fx, 0, w); + let [yv, cb, cr] = o.standard.ycbcr(c); + if let Some(y) = o.row(yv) { + bump(s, 0, x, y); + bump(s, 1, x, y); + } + if let Some(y) = o.row((cb * cb + cr * cr).sqrt()) { + bump(s, 2, x, y); + } + } + ScopeKind::ParadeRgb | ScopeKind::ParadeYuv => { + let third = (w / 3).max(1); + let vals = if kind == ScopeKind::ParadeRgb { + c + } else { + let [y, cb, cr] = o.standard.ycbcr(c); + // Colour differences centred on mid-scale. + [y, cb + 0.5, cr + 0.5] + }; + for (ch, v) in vals.iter().enumerate() { + let x = col(fx, ch * third, third); + if let Some(y) = o.row(*v) { + // RGB parade: tinted per channel; YUV: Y white, Cb blue, Cr red. + match (kind, ch) { + (ScopeKind::ParadeYuv, 0) => { + for p in 0..3 { + bump(s, p, x, y); + } + } + (ScopeKind::ParadeYuv, 1) => bump(s, 2, x, y), + (ScopeKind::ParadeYuv, _) => bump(s, 0, x, y), + _ => bump(s, ch, x, y), + } + } + } + } + ScopeKind::VectorscopeYuv => { + let [_, cb, cr] = o.standard.ycbcr(c); + if let Some((x, y)) = vec_cell(s, cb, cr) { + for ch in 0..3 { + bump(s, ch, x, y); + } + } + } + ScopeKind::VectorscopeHls => { + let (hue, _, sat) = hls(c); + let a = hue.to_radians(); + if let Some((x, y)) = vec_cell(s, sat * 0.5 * a.cos(), sat * 0.5 * a.sin()) { + for ch in 0..3 { + bump(s, ch, x, y); + } + } + } + ScopeKind::Histogram => { + let (lo, hi) = o.range(); + for (ch, v) in c.iter().enumerate() { + let f = (v - lo) / (hi - lo); + if (0.0..=1.0).contains(&f) { + let x = ((f * (w - 1) as f32).round() as usize).min(w - 1); + // Histograms keep their counts in row 0; the bars are drawn by `histogram_bars`. + bump(s, ch, x, 0); + } + } + } + } +} + +/// Vectorscope cell of (u, v) in −0.5…0.5 (the disc fills the plot; +v up). +fn vec_cell(s: &Scope, u: f32, v: f32) -> Option<(usize, usize)> { + let (w, h) = (s.width as f32, s.height as f32); + let x = ((u + 0.5) * (w - 1.0)).round(); + let y = ((0.5 - v) * (h - 1.0)).round(); + if x < 0.0 || y < 0.0 || x >= w || y >= h { + return None; + } + Some((x as usize, y as usize)) +} + +/// (hue degrees, lightness, saturation) of straight RGB (HLS). +pub fn hls(c: [f32; 3]) -> (f32, f32, f32) { + let (r, g, b) = (c[0], c[1], c[2]); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) * 0.5; + let d = max - min; + if d < 1e-6 { + return (0.0, l, 0.0); + } + let s = if l > 0.5 { d / (2.0 - max - min).max(1e-6) } else { d / (max + min).max(1e-6) }; + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h * 60.0, l, s.min(1.0)) +} + +/// Histogram bars: per channel, `bins` counts across the value range (counts in a +/// [`ScopeKind::Histogram`] scope's first row). +pub fn histogram_bars(s: &Scope) -> [Vec; 3] { + let w = s.width as usize; + [s.counts[0][..w].to_vec(), s.counts[1][..w].to_vec(), s.counts[2][..w].to_vec()] +} + +/// Where a vectorscope target (75 % colour bars' primaries and secondaries) sits, as (u, v) in +/// −0.5…0.5, for the graticule. +pub fn vector_targets(standard: ColorStandard, hls_mode: bool) -> Vec<(&'static str, [f32; 2])> { + let cols: [(&str, [f32; 3]); 6] = [ + ("R", [0.75, 0.0, 0.0]), + ("Mg", [0.75, 0.0, 0.75]), + ("B", [0.0, 0.0, 0.75]), + ("Cy", [0.0, 0.75, 0.75]), + ("G", [0.0, 0.75, 0.0]), + ("Yl", [0.75, 0.75, 0.0]), + ]; + cols.iter() + .map(|(n, c)| { + if hls_mode { + let (h, _, s) = hls(*c); + let a = h.to_radians(); + (*n, [s * 0.5 * a.cos(), s * 0.5 * a.sin()]) + } else { + let [_, cb, cr] = standard.ycbcr(*c); + (*n, [cb, cr]) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, c: [f32; 3]) -> Image { + Image::filled(w, h, [c[0], c[1], c[2], 1.0]) + } + + #[test] + fn waveform_of_flat_grey_is_one_line() { + let img = solid(64, 32, [0.5, 0.5, 0.5]); + let o = ScopeOpts { width: 64, height: 101, ..Default::default() }; + let s = compute(&img, ScopeKind::WaveformLuma, &o); + // 0.5 sits on row 50 of 0..=100; every sample lands there. + assert_eq!(s.total(0), 64 * 32); + for x in 0..64 { + assert_eq!(s.counts[0][50 * 64 + x], 32); + } + } + + #[test] + fn waveform_rgb_ramps_follow_columns() { + // Horizontal ramp: column x has value x / (w - 1). + let w = 101; + let mut img = Image::new(w, 4); + for y in 0..4 { + for x in 0..w { + let v = x as f32 / (w - 1) as f32; + img.set(x, y, [v, 1.0 - v, 0.0, 1.0]); + } + } + let o = ScopeOpts { width: w, height: 101, ..Default::default() }; + let s = compute(&img, ScopeKind::WaveformRgb, &o); + for x in 0..w as usize { + // Red: row 100 - x; green: row x. + assert_eq!(s.counts[0][(100 - x) * w as usize + x], 4, "red column {x}"); + assert_eq!(s.counts[1][x * w as usize + x], 4, "green column {x}"); + } + } + + #[test] + fn luma_coefficients_per_standard() { + let red = [1.0, 0.0, 0.0]; + assert!((ColorStandard::Rec601.ycbcr(red)[0] - 0.299).abs() < 1e-6); + assert!((ColorStandard::Rec709.ycbcr(red)[0] - 0.2126).abs() < 1e-6); + assert!((ColorStandard::Rec2020.ycbcr(red)[0] - 0.2627).abs() < 1e-6); + // Pure red has Cr = +0.5 in every standard, Cb negative. + for st in ColorStandard::ALL { + let [_, cb, cr] = st.ycbcr(red); + assert!((cr - 0.5).abs() < 1e-6); + assert!(cb < 0.0); + // White and grey have no chroma. + let [_, cb, cr] = st.ycbcr([0.6, 0.6, 0.6]); + assert!(cb.abs() < 1e-6 && cr.abs() < 1e-6); + } + } + + #[test] + fn vectorscope_places_red_on_its_target() { + let img = solid(16, 16, [1.0, 0.0, 0.0]); + let o = ScopeOpts { width: 101, height: 101, ..Default::default() }; + let s = compute(&img, ScopeKind::VectorscopeYuv, &o); + let [_, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]); + let (x, y) = s.peak(0); + assert_eq!(x, ((cb + 0.5) * 100.0).round() as u32); + assert_eq!(y, ((0.5 - cr) * 100.0).round() as u32); + // Grey sits in the centre. + let g = compute(&solid(8, 8, [0.3, 0.3, 0.3]), ScopeKind::VectorscopeYuv, &o); + assert_eq!(g.peak(0), (50, 50)); + // HLS: fully saturated green at 120° on the rim. + let h = compute(&solid(8, 8, [0.0, 1.0, 0.0]), ScopeKind::VectorscopeHls, &o); + let a = 120f32.to_radians(); + assert_eq!(h.peak(0), (((0.5 * a.cos() + 0.5) * 100.0).round() as u32, ((0.5 - 0.5 * a.sin()) * 100.0).round() as u32)); + } + + #[test] + fn histogram_counts_and_clamp() { + // Half black, half white. + let mut img = Image::new(10, 10); + for y in 0..10 { + for x in 0..10 { + let v = if x < 5 { 0.0 } else { 1.0 }; + img.set(x, y, [v, v, v, 1.0]); + } + } + let o = ScopeOpts { width: 256, height: 64, ..Default::default() }; + let s = compute(&img, ScopeKind::Histogram, &o); + let bars = histogram_bars(&s); + for b in &bars { + assert_eq!(b[0], 50); + assert_eq!(b[255], 50); + assert_eq!(b.iter().sum::(), 100); + } + // Super-white: off scale in 8-bit unclamped, on the float scale, pinned to 1 clamped. + let hot = solid(4, 4, [1.2, 1.2, 1.2]); + let unclamped = ScopeOpts { clamp: false, ..o }; + assert_eq!(compute(&hot, ScopeKind::Histogram, &unclamped).total(0), 0); + let fl = ScopeOpts { clamp: false, float: true, ..o }; + let f = histogram_bars(&compute(&hot, ScopeKind::Histogram, &fl)); + let expect = (((1.2 + 0.25) / 1.5) * 255.0f32).round() as usize; + assert_eq!(f[0][expect], 16); + let c = histogram_bars(&compute(&hot, ScopeKind::Histogram, &o)); + assert_eq!(c[0][255], 16); + } + + #[test] + fn parade_splits_channels_into_thirds() { + let img = solid(30, 3, [1.0, 0.5, 0.0]); + let o = ScopeOpts { width: 90, height: 101, ..Default::default() }; + let s = compute(&img, ScopeKind::ParadeRgb, &o); + // Red trace in the first third at the top, green mid-height in the second, blue at the + // bottom of the third. + let (rx, ry) = s.peak(0); + let (gx, gy) = s.peak(1); + let (bx, by) = s.peak(2); + assert!(rx < 30 && ry == 0); + assert!((30..60).contains(&gx) && gy == 50); + assert!((60..90).contains(&bx) && by == 100); + let yuv = compute(&solid(30, 3, [0.5, 0.5, 0.5]), ScopeKind::ParadeYuv, &o); + // Grey: Y = 0.5 and both colour differences at mid-scale. + assert_eq!(yuv.peak(1).1, 50); + assert_eq!(yuv.peak(2).1, 50); + } + + #[test] + fn yc_waveform_has_chroma_trace() { + let img = solid(8, 8, [1.0, 0.0, 0.0]); + let o = ScopeOpts { width: 8, height: 101, ..Default::default() }; + let s = compute(&img, ScopeKind::WaveformYc, &o); + let [y, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]); + assert_eq!(s.peak(0).1, ((1.0 - y) * 100.0).round() as u32); + assert_eq!(s.peak(2).1, ((1.0 - (cb * cb + cr * cr).sqrt()) * 100.0).round() as u32); + } +} From 878b47b5edabb21298e9be451bddf71550b7deeb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:23:01 -0400 Subject: [PATCH 03/20] M12.7: adjustment layers on the GPU (effect stacks resident on the device, CPU fallback per non-GPU effect) Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 22 ++++++ crates/gpu/src/effects.rs | 42 +++++++++++ crates/gpu/src/lib.rs | 2 + crates/gpu/src/ops.rs | 11 +++ crates/gpu/src/shaders/kernels.wgsl | 16 +++++ crates/gpu/src/tests_adjust.rs | 105 ++++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 38 +++++++++- crates/render/src/lib.rs | 85 ++++++++++++++++++---- 8 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 crates/gpu/src/tests_adjust.rs diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d549149d4..66bee99d5 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -26,6 +26,7 @@ const ENTRIES: &[&str] = &[ "shadow_make", "shadow_combine", "pointwise", + "adjust_mix", ]; /// Entry points that also bind group 1 (four read-only storage buffers; see @@ -582,6 +583,27 @@ impl<'g> Enc<'g> { self.bytes(bytes) } + /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image + /// through `data`: (buffer, row length in pixels). + pub fn to_buffer(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("image rows"), + size: row as u64 * img.height as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.encoder().copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &img.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(img.height) }, + }, + wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, + ); + (buf, row / 16) + } + /// A read-only storage buffer holding `bytes` (padded to 16 bytes). pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { while bytes.len() < 16 || bytes.len() % 4 != 0 { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b33..36b692f13 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -62,6 +62,48 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option Some(Buf { img, offset: b.offset, scale: b.scale }) } +/// An effect stack running on a GPU-resident image (adjustment layers): GPU chains stay on +/// the device; CPU effects read the image back, run, and upload the result. +pub(crate) struct GpuFx<'e, 'g> { + e: &'e mut Enc<'g>, + b: Option, + levels: Option, +} + +impl<'e, 'g> GpuFx<'e, 'g> { + pub fn new(e: &'e mut Enc<'g>, img: GpuImage, offset: [f64; 2], scale: f64, levels: Option) -> Self { + GpuFx { e, b: Some(GBuf { img, offset, scale }), levels } + } + + /// The result: (image, offset, scale); `None` when a step failed. + pub fn finish(self) -> Option<(GpuImage, [f64; 2], f64)> { + self.b.map(|b| (b.img, b.offset, b.scale)) + } +} + +impl effectcraft_render::FxTarget for GpuFx<'_, '_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let Some(b) = &self.b else { return false }; + let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + for step in steps { + let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + cur = next; + if let Some(l) = self.levels { + cur.img = ops::quantize(self.e, &cur.img, l); + } + } + self.b = Some(cur); + true + } + + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + let Some(b) = self.b.take() else { return }; + let Some(img) = self.e.download(&b.img) else { return }; + let out = f(Buf { img, offset: b.offset, scale: b.scale }); + self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); + } +} + fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { match id { "ec.blur.gaussian" => gaussian(e, ctx, b), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7a2634b3d..a7c9cdc4c 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -143,4 +143,6 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_adv3d; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 775e86c13..cd743fbf3 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -134,6 +134,17 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo out } +/// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`. +pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage { + let (rows, stride) = e.to_buffer(matte); + let mut p = Params::default(); + p.u[0][0] = stride; + p.f[0][0] = opacity; + let out = e.image(canvas.width, canvas.height); + e.pixels("adjust_mix", &p, canvas, Some(adjusted), &out, Some(&rows)); + out +} + /// Clamp and quantise to `levels` steps (8/16 bpc). pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { let mut p = Params::default(); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index c94076a02..443abcf9c 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -828,3 +828,19 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } textureStore(out, p, px); } + +// Adjustment layer finish (Renderer::draw_adjustment): canvas `src` moves toward the adjusted +// comp `aux` by the footprint's alpha × opacity. The footprint is `data` (RGBA f32 rows of +// u[0].x pixels); f[0].x = opacity. +@compute @workgroup_size(16, 16) +fn adjust_mix(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let c = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; + textureStore(out, p, c + (a - c) * k); +} diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs new file mode 100644 index 000000000..ae7f88da3 --- /dev/null +++ b/crates/gpu/src/tests_adjust.rs @@ -0,0 +1,105 @@ +//! Adjustment layers on the GPU vs the CPU: GPU-only stacks (resident on the device), mixed +//! stacks (CPU effects in between read back and upload), masks, opacity, colour management. + +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, ColorSpace, MaskMode}; +use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer}; +use effectcraft_time::Tick; + +use crate::tests::{Pattern, Scene, c, check, compare_at, gpu, n, opts, set}; + +fn adjustment_scene(depth: BitDepth, stack: &[(&str, Vec<(&str, Value)>)], masked: bool) -> Scene { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut l = s.footage(60, 40); + set(&mut l, "transform/rotation", Value::Scalar(15.0)); + s.push(l); + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + set(&mut adj, "transform/opacity", Value::Scalar(75.0)); + set(&mut adj, "transform/rotation", Value::Scalar(-6.0)); + for (id, vals) in stack { + s.effect(&mut adj, id, vals); + } + if masked { + let mut next = s.p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 25.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]); + s.p.next_id = next; + adj.props.sub_mut("masks").unwrap().children.push(m.into()); + } + s.push(adj); + let top = s.solid([0.2, 0.6, 0.9], 20, 20); + s.push(top); + s +} + +fn gpu_stack() -> Vec<(&'static str, Vec<(&'static str, Value)>)> { + vec![ + ("ec.blur.gaussian", vec![("blurriness", n(6.0))]), + ("ec.color.levels", vec![("inBlack", n(0.1)), ("gamma", n(1.4))]), + ("ec.color.tint", vec![("black", c(0.1, 0.0, 0.2)), ("amount", n(60.0))]), + ] +} + +#[test] +fn gpu_adjustment_chains_match_the_cpu() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + for masked in [false, true] { + let s = adjustment_scene(depth, &gpu_stack(), masked); + check(&format!("gpu adjustment {depth:?} masked {masked}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("gpu adjustment {depth:?} masked {masked} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn mixed_adjustment_chains_fall_back_per_effect() { + let mut stack = gpu_stack(); + stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))])); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let s = adjustment_scene(depth, &stack, true); + check(&format!("mixed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } + // Colour-managed: effects run in the working space, compositing in linear light. + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = adjustment_scene(depth, &stack, false); + s.p.settings.working_space = Some(ColorSpace::Rec709); + s.p.settings.blend_linear = true; + check(&format!("colour-managed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + +/// Counts where each run of the stack goes. +#[derive(Default)] +struct Probe { + gpu: usize, + cpu: usize, +} + +impl FxTarget for Probe { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + self.gpu += steps.len(); + true + } + fn cpu(&mut self, _f: &mut dyn FnMut(effectcraft_effects::Buf) -> effectcraft_effects::Buf) { + self.cpu += 1; + } +} + +#[test] +fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { + let Some(g) = gpu() else { return }; + let mut stack = gpu_stack(); + stack.insert(1, ("ec.stylize.posterize", vec![])); + let s = adjustment_scene(BitDepth::Bpc32, &stack, false); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let adj = ctx.comp.layers.iter().find(|l| l.switches.adjustment).unwrap(); + let mut p = Probe::default(); + r.run_effects_on(&ctx, adj, true, &mut p); + assert_eq!((p.gpu, p.cpu), (3, 1)); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index a0b51f1fd..cfec880da 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -124,9 +124,14 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: & } fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { - if layer.switches.adjustment || layer.switches.quality == Quality::Wireframe { - // Adjustment layers run their effects on the comp below (CPU, with GPU effects where - // available); wireframes draw outlines. + if layer.switches.adjustment { + if draw_adjustment(e, r, ctx, layer, canvas).is_some() { + return Some(()); + } + return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); + } + if layer.switches.quality == Quality::Wireframe { + // Wireframes draw outlines on the CPU. return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); } let opacity = r.layer_opacity(ctx, layer); @@ -162,6 +167,33 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, +/// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result +/// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. +fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { + let opacity = r.layer_opacity(ctx, layer); + let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; + let (w, h) = (canvas.width, canvas.height); + let matte = place(e, r, ctx, layer, &Arc::new(foot), &e.image(w, h), BlendMode::Normal, 1.0)?; + let pipe = r.pipe(); + // Effects run in the working space, not the linear blending space. + let below = match pipe.from_blend() { + Some(c) => ops::convert(e, canvas, &c), + None => canvas.clone(), + }; + let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); + r.run_effects_on(ctx, layer, true, &mut fx); + let (mut adjusted, _, _) = fx.finish()?; + if adjusted.width != w || adjusted.height != h { + return Some(()); + } + if let Some(c) = pipe.to_blend() { + adjusted = ops::convert(e, &adjusted, &c); + } + *canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity); + Some(()) +} + fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc, canvas: &mut GpuImage, opacity: f32) -> Option<()> { let matte = r.track_matte(ctx, layer).is_some(); if !matte && !layer.preserve_transparency { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5ae4ebc5d..ffb559369 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -242,6 +242,41 @@ pub trait Accelerator: Send + Sync { } } +/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image +/// resident on an accelerator. +pub trait FxTarget { + /// Run a chain of accelerator-supported effects (quantising after each to the bit depth). + /// `false` = not handled: the effects then run one by one through [`FxTarget::cpu`]. + fn gpu(&mut self, steps: &[FxStep]) -> bool; + /// Run one CPU effect: `f` maps the buffer. + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); +} + +/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). +struct CpuFx<'a> { + accel: Option<&'a dyn Accelerator>, + levels: Option, + buf: Option, +} + +impl FxTarget for CpuFx<'_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let (Some(a), Some(b)) = (self.accel, &self.buf) else { return false }; + match a.effects(steps, b, self.levels) { + Some(out) => { + self.buf = Some(out); + true + } + None => false, + } + } + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + if let Some(b) = self.buf.take() { + self.buf = Some(f(b)); + } + } +} + #[derive(Clone, Copy, Debug)] pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). @@ -592,19 +627,28 @@ impl<'a> Renderer<'a> { } /// Run the first `limit` effects of the layer's stack (by position; disabled ones count). - fn apply_effects_timed( + fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf { + let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) }; + self.run_effect_stack(ctx, layer, adjustment, limit, timing, &mut t); + t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale }) + } + + /// Run the layer's video effect stack on `target`: runs of GPU-capable effects go to + /// [`FxTarget::gpu`] (when an accelerator is active and supports them), the rest to + /// [`FxTarget::cpu`] one by one (quantised to the bit depth after each). + fn run_effect_stack( &self, ctx: &EvalCtx, layer: &Layer, - mut buf: Buf, adjustment: bool, limit: usize, mut timing: Option<&mut Vec<(String, f64)>>, - ) -> Buf { + target: &mut dyn FxTarget, + ) { if !layer.switches.effects { - return buf; + return; } - let Some(fx) = layer.effects() else { return buf }; + let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -635,7 +679,7 @@ impl<'a> Renderer<'a> { let mut k = 0; while k < stack.len() { let (i, g, spec) = stack[k]; - // A run of GPU effects goes to the accelerator in one upload / readback. + // A run of GPU effects goes to the accelerator in one go. if let Some(a) = accel && spec.gpu && a.supports_effect(spec.id) @@ -651,8 +695,7 @@ impl<'a> Renderer<'a> { }) .collect(); let t0 = web_time::Instant::now(); - if let Some(out) = a.effects(&steps, &buf, self.pipe.levels) { - buf = out; + if target.gpu(&steps) { if let Some(v) = timing.as_deref_mut() { let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64; v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms))); @@ -667,14 +710,16 @@ impl<'a> Renderer<'a> { let env = EffectEnv { effect_index: i, ..env }; let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); - buf = effectcraft_effects::apply(spec, &ectx, buf); - // 8/16 bpc effects write integer pixels. - self.pipe.quantize(&mut buf.img); + target.cpu(&mut |buf| { + let mut buf = effectcraft_effects::apply(spec, &ectx, buf); + // 8/16 bpc effects write integer pixels. + self.pipe.quantize(&mut buf.img); + buf + }); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); } } - buf } /// Source pixels of a layer in layer space (before masks/effects). @@ -1389,6 +1434,22 @@ impl<'a> Renderer<'a> { three_d::compose::gpu_run(self, ctx, run, out) } + /// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable + /// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's + /// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment + /// layers). + pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) { + self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, target); + } + + /// An adjustment layer's footprint (its source with masks applied, layer space): where + /// its effects show. `None` = the layer changes nothing. + pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let mut foot = self.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot); + Some(foot) + } + /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { self.draw_layer(ctx, layer, canvas, false); From 9a6389db86a0109fddff225533b900141d77e6b0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:25:12 -0400 Subject: [PATCH 04/20] M12.7: GPU effect family modules (colour, distort, generate) scaffolding; clippy fixes Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/classic3d.rs | 2 +- crates/gpu/src/context.rs | 17 ++++++++++++++--- crates/gpu/src/effects.rs | 21 ++++++++++++--------- crates/gpu/src/fx_color.rs | 18 ++++++++++++++++++ crates/gpu/src/fx_distort.rs | 18 ++++++++++++++++++ crates/gpu/src/fx_generate.rs | 18 ++++++++++++++++++ crates/gpu/src/lib.rs | 3 +++ crates/gpu/src/ops.rs | 2 +- crates/gpu/src/shaders/fx_color.wgsl | 2 ++ crates/gpu/src/shaders/fx_distort.wgsl | 2 ++ crates/gpu/src/shaders/fx_generate.wgsl | 2 ++ crates/gpu/src/tests.rs | 4 ++-- 12 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 crates/gpu/src/fx_color.rs create mode 100644 crates/gpu/src/fx_distort.rs create mode 100644 crates/gpu/src/fx_generate.rs create mode 100644 crates/gpu/src/shaders/fx_color.wgsl create mode 100644 crates/gpu/src/shaders/fx_distort.wgsl create mode 100644 crates/gpu/src/shaders/fx_generate.wgsl diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 9e73edb3f..0c72dd601 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -188,7 +188,7 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option e.g.device.limits().max_storage_buffer_binding_size as u64 { + if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size { return None; } let mut lights = Words::default(); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 66bee99d5..e7cda93e4 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -141,7 +141,15 @@ impl GpuContext { } } let name = format!("{} ({:?})", info.name, info.backend); - let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/classic3d.wgsl")].concat(); + let src = [ + include_str!("shaders/common.wgsl"), + include_str!("shaders/kernels.wgsl"), + include_str!("shaders/classic3d.wgsl"), + include_str!("shaders/fx_color.wgsl"), + include_str!("shaders/fx_distort.wgsl"), + include_str!("shaders/fx_generate.wgsl"), + ] + .concat(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -190,6 +198,9 @@ impl GpuContext { }); let pipelines = ENTRIES .iter() + .chain(crate::fx_color::KERNELS) + .chain(crate::fx_distort::KERNELS) + .chain(crate::fx_generate::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { @@ -585,7 +596,7 @@ impl<'g> Enc<'g> { /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image /// through `data`: (buffer, row length in pixels). - pub fn to_buffer(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("image rows"), @@ -606,7 +617,7 @@ impl<'g> Enc<'g> { /// A read-only storage buffer holding `bytes` (padded to 16 bytes). pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { - while bytes.len() < 16 || bytes.len() % 4 != 0 { + while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 36b692f13..2ef75c48c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -16,19 +16,19 @@ pub fn supports(id: &str) -> bool { } /// A layer buffer on the GPU (see [`Buf`]). -struct GBuf { - img: GpuImage, - offset: [f64; 2], - scale: f64, +pub(crate) struct GBuf { + pub img: GpuImage, + pub offset: [f64; 2], + pub scale: f64, } impl GBuf { - fn to_px(&self, p: [f64; 2]) -> (f64, f64) { + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } /// Buf::pad: grow by `pad` transparent pixels on every side. - fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { if pad == 0 { return Some(()); } @@ -114,6 +114,9 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), _ if id.starts_with("ec.control.") => Some(b), + _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), + _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), + _ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } @@ -121,7 +124,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { // ---------------------------------------------------------------- blurs /// raster::blur::box_radii. -fn box_radii(sigma: f64, n: usize) -> Vec { +pub(crate) fn box_radii(sigma: f64, n: usize) -> Vec { if sigma <= 0.0 { return vec![0; n]; } @@ -137,7 +140,7 @@ fn box_radii(sigma: f64, n: usize) -> Vec { } /// Horizontal passes with radii `rx`, then vertical passes with radii `ry` (raster::blur). -fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { +pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { @@ -153,7 +156,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b } /// raster::gaussian_blur (3 box passes per axis). -fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { +pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] }; let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] }; box_passes(e, img, &rx, &ry, repeat) diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs new file mode 100644 index 000000000..8205f0423 --- /dev/null +++ b/crates/gpu/src/fx_color.rs @@ -0,0 +1,18 @@ +//! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_color.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { + None +} diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs new file mode 100644 index 000000000..fe92ad919 --- /dev/null +++ b/crates/gpu/src/fx_distort.rs @@ -0,0 +1,18 @@ +//! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_distort.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { + None +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs new file mode 100644 index 000000000..61c18defc --- /dev/null +++ b/crates/gpu/src/fx_generate.rs @@ -0,0 +1,18 @@ +//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. + +use effectcraft_effects::EffectCtx; + +use crate::context::Enc; +use crate::effects::GBuf; + +/// Compute entry points in `fx_generate.wgsl`. +pub(crate) const KERNELS: &[&str] = &[]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { + None +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a7c9cdc4c..9ebce2090 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -25,6 +25,9 @@ mod adv3d; mod classic3d; mod context; mod effects; +mod fx_color; +mod fx_distort; +mod fx_generate; mod ops; mod walk; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index cd743fbf3..e7a9c406f 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -136,7 +136,7 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo /// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`. pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage { - let (rows, stride) = e.to_buffer(matte); + let (rows, stride) = e.image_rows(matte); let mut p = Params::default(); p.u[0][0] = stride; p.f[0][0] = opacity; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl new file mode 100644 index 000000000..3e42688d2 --- /dev/null +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -0,0 +1,2 @@ +// GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl new file mode 100644 index 000000000..b3ce34636 --- /dev/null +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -0,0 +1,2 @@ +// GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl new file mode 100644 index 000000000..bc67b93ae --- /dev/null +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -0,0 +1,2 @@ +// GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 137fdec3d..f795b58d2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -379,7 +379,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { /// The effect alone on a buffer: GPU kernels vs the CPU effect (32-bit float), including the /// buffer geometry (padding, offset). -fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { +pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { let Some(g) = gpu() else { return }; let spec = effectcraft_effects::find(id).unwrap(); let size = [70.0, 44.0]; @@ -401,7 +401,7 @@ fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { } /// One footage layer with `fx` applied, composited over a background, at 8 and 32 bpc. -fn effect_case(id: &str, vals: &[(&str, Value)]) { +pub(crate) fn effect_case(id: &str, vals: &[(&str, Value)]) { effect_direct(id, vals, false); effect_direct(id, vals, true); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { From fcc09cf09555c91c12584386514cc1ffcb45378c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:42:43 -0400 Subject: [PATCH 05/20] M12.7: GPU distort/stylize effects (Turbulent Displace, Displacement Map, Wave Warp, Ripple, Twirl, Bulge, CC Lens, Mesh Warp, Mosaic, Find Edges, Emboss) Compute kernels in shaders/fx_distort.wgsl mirroring the CPU effects; per-row/column structure (map coordinates, wave phase, tile bounds, mesh triangle buckets) is computed in f64 on the CPU and uploaded. Displacement Map fetches its map layer through the effect host as the CPU does and samples it on the GPU. CPU oracle tests in tests_fx_distort.rs. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort2.rs | 3 +- crates/effects/src/lib.rs | 12 + crates/gpu/src/fx_distort.rs | 405 +++++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_distort.wgsl | 552 +++++++++++++++++++++++++ crates/gpu/src/tests_fx_distort.rs | 119 ++++++ 6 files changed, 1087 insertions(+), 6 deletions(-) create mode 100644 crates/gpu/src/tests_fx_distort.rs diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 66f038e0d..2d52f0397 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -414,7 +414,8 @@ fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], src out } -fn parse_mesh(s: &str) -> Vec { +/// Mesh Warp's `mesh` parameter: the vertex offsets (layer pixels), row-major. +pub fn parse_mesh(s: &str) -> Vec<(f64, f64)> { s.split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) .filter_map(|t| { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f3465..7c6e05740 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -62,6 +62,7 @@ use std::sync::OnceLock; pub use color_fx::{exposure_settings, levels_clip}; pub use color2::Curve; +pub use distort2::parse_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; @@ -641,6 +642,17 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index fe92ad919..bcd7214f4 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -1,18 +1,413 @@ //! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Parameter conversions, padding and anything with a per-row or per-column structure (map +//! coordinates, tile bounds, Mesh Warp's triangle buckets) are computed here in f64 exactly as +//! the CPU does and uploaded; the kernels do the per-pixel work. + +use std::f64::consts::TAU; use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{Src, src_at}; -use crate::context::Enc; +use crate::context::{Enc, Params}; use crate::effects::GBuf; /// Compute entry points in `fx_distort.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "dst_twirl", + "dst_bulge", + "dst_wavewarp", + "dst_ripple", + "dst_cclens", + "dst_turbulent", + "dst_displace", + "dst_mesh", + "dst_mosaic_rows", + "dst_mosaic_tiles", + "dst_mosaic_out", + "dst_mosaic_sharp", + "dst_findedges", + "dst_emboss", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", +]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. -pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.distort.turbulentdisplace" => turbulent(e, ctx, b), + "ec.distort.displacementmap" => displacement_map(e, ctx, b), + "ec.distort.wavewarp" => wave_warp(e, ctx, b), + "ec.distort.ripple" => ripple(e, ctx, b), + "ec.distort.twirl" => twirl(e, ctx, b), + "ec.distort.bulge" => bulge(e, ctx, b), + "ec.distort.cclens" => cc_lens(e, ctx, b), + "ec.distort.meshwarp" => mesh_warp(e, ctx, b), + "ec.stylize.mosaic" => mosaic(e, ctx, b), + "ec.stylize.findedges" => find_edges(e, ctx, b), + "ec.stylize.emboss" => emboss(e, ctx, b), + _ => None, + } +} + +/// Run a same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, None, &out, data); + b.img = out; + Some(b) +} + +/// Buf::layer_w / layer_h (distort.rs). +fn layer_wh(ctx: &EffectCtx) -> (f64, f64) { + (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)) +} + +/// util::layer_rect. +fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> [f32; 4] { + [b.offset[0] as f32, b.offset[1] as f32, (ctx.layer_size[0] * b.scale) as f32, (ctx.layer_size[1] * b.scale) as f32] +} + +// ---------------------------------------------------------------- distort.rs + +fn twirl(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32]; + run(e, "dst_twirl", &p, b, None) +} + +fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rx = (ctx.params.f("hradius") * b.scale).max(1.0); + let ry = (ctx.params.f("vradius") * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let h = ctx.params.f("height"); + let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0; + let pin = ctx.params.b("pinAllEdges"); + let (lw, lh) = layer_wh(ctx); + let (lw, lh) = (lw * b.scale, lh * b.scale); + let edge = (lw.min(lh) * 0.1).max(1.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32]; + p.f[1] = [h as f32, taper as f32, if pin { 1.0 } else { 0.0 }, edge as f32]; + p.f[2] = [b.offset[0] as f32, b.offset[1] as f32, lw as f32, lh as f32]; + run(e, "dst_bulge", &p, b, None) +} + +fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let h = ctx.params.f("height") * b.scale; + let w = (ctx.params.f("width") * b.scale).max(1.0); + let dir = ctx.params.f("direction").to_radians(); + let speed = ctx.params.f("speed"); + let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU; + let (dx, dy) = (dir.sin(), -dir.cos()); + if !ctx.adjustment { + b.pad(e, h.abs().ceil() as u32 + 1)?; + } + let (lw, lh) = layer_wh(ctx); + let rect = [b.offset[0] as f32, b.offset[1] as f32, (lw * b.scale).max(1.0) as f32, (lh * b.scale).max(1.0) as f32]; + // The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column + // and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact + // to f32 (the circle shapes have vertical tangents where a phase error is amplified). + let (bw, bh) = (b.img.width as usize, b.img.height as usize); + let mut data = vec![0.0f32; 2 * (bw + bh)]; + let split = |v: f64| (v - v.floor(), v.floor()); + for x in 0..bw { + let (f, i) = split((x as f64 + 0.5) * dx / w); + data[x] = f as f32; + data[bw + x] = i as f32; + } + for y in 0..bh { + let (f, i) = split((y as f64 + 0.5) * dy / w + phase / TAU); + data[2 * bw + y] = f as f32; + data[2 * bw + bh + y] = i as f32; + } + let mut p = Params::default(); + p.u[0] = [ctx.params.e("waveType"), ctx.params.e("pinning"), ctx.seed ^ 0x3a7e, bw as u32]; + p.f[0] = [dx as f32, dy as f32, h as f32, 0.0]; + p.f[2] = rect; + let buf = e.data(&data); + run(e, "dst_wavewarp", &p, b, Some(&buf)) +} + +fn ripple(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * 0.5 * b.scale).max(1.0); + let asymmetric = ctx.params.e("conversion") == 0; + let c = b.to_px(ctx.params.v2("center")); + let w = (ctx.params.f("waveWidth") * b.scale).max(1.0); + let h = ctx.params.f("waveHeight") * b.scale; + let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * TAU; + let mut p = Params::default(); + p.u[0][0] = asymmetric as u32; + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, w as f32]; + p.f[1] = [h as f32, phase.rem_euclid(TAU) as f32, 0.0, 0.0]; + run(e, "dst_ripple", &p, b, None) +} + +// ---------------------------------------------------------------- distort2.rs + +fn cc_lens(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5); + let k = 2f64.powf(ctx.params.f("convergence") / 50.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, k as f32]; + run(e, "dst_cclens", &p, b, None) +} + +fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let amount = ctx.params.f("amount") * b.scale; + if amount.abs() < 1e-9 { + return Some(b); + } + let kind = ctx.params.e("displacement"); + let size = (ctx.params.f("size") * b.scale).max(1.0); + if ctx.params.b("resizeLayer") && !ctx.adjustment { + b.pad(e, amount.abs().ceil() as u32 + 1)?; + } + let off = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity").clamp(1.0, 10.0); + let evo = ctx.params.f("evolution") / 360.0; + let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); + let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; + let rect = layer_rect(ctx, &b); + let edge = ((ctx.layer_size[0] * b.scale).min(ctx.layer_size[1] * b.scale) * 0.1).max(1.0); + let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); + let (z0, z1, t) = match cycle { + Some(c) => { + let z = evo.rem_euclid(c); + (z as f32, (z - c) as f32, (z / c) as f32) + } + None => (evo as f32, 0.0, 0.0), + }; + let mut p = Params::default(); + p.u[0] = [kind, ctx.params.e("pinning"), seed, oct.ceil().max(1.0) as u32]; + p.u[1][0] = cycle.is_some() as u32; + p.f[0] = [off.0 as f32, off.1 as f32, size as f32, amount as f32]; + p.f[1] = [z0, z1, t, (oct - oct.floor()) as f32]; + p.f[2] = rect; + p.f[3] = [edge as f32, falloff, 0.0, 0.0]; + run(e, "dst_turbulent", &p, b, None) +} + +fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hs = src_at(ctx.params.e("useForHorizontal")); + let vs = src_at(ctx.params.e("useForVertical")); + let mh = ctx.params.f("maxHorizontal") * b.scale; + let mv = ctx.params.f("maxVertical") * b.scale; + let wrap = ctx.params.b("wrapPixelsAround"); + if ctx.params.b("expandOutput") && !wrap && !ctx.adjustment { + b.pad(e, mh.abs().max(mv.abs()).ceil() as u32)?; + } + let layer = ctx.layer_param("displacementMapLayer", false); + let behavior = ctx.params.e("displacementMapBehavior"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inv = 1.0 / b.scale.max(1e-9); + let ls = ctx.layer_size; + // Per column / row: the map layer's buffer coordinate (fit_layer / Tile Map) and whether + // the pixel lies inside the centred map (Center Map). + let mut data = vec![0.0f32; 2 * (w + h)]; + let mut mode = 0u32; + let mut center = false; + let map = match &layer { + Some(o) => { + if o.buf.img.is_empty() { + return None; + } + let os = o.size; + let axis = |i: usize, k: usize| -> (f64, bool) { + let q = (i as f64 + 0.5 - b.offset[k]) * inv; + let map_q = match behavior { + 2 => (q + (os[k] - ls[k]) * 0.5).rem_euclid(os[k].max(1.0)), + 1 if ls[0] > 0.0 && ls[1] > 0.0 => q * (os[k] / ls[k]), + 1 => q, + _ => q + (os[k] - ls[k]) * 0.5, + }; + let c = q + (os[k] - ls[k]) * 0.5; + (map_q * o.buf.scale + o.buf.offset[k], (0.0..os[k]).contains(&c)) + }; + for x in 0..w { + let (m, inside) = axis(x, 0); + data[x] = m as f32; + data[w + h + x] = inside as u32 as f32; + } + for y in 0..h { + let (m, inside) = axis(y, 1); + data[w + y] = m as f32; + data[2 * w + h + y] = inside as u32 as f32; + } + mode = if behavior == 2 { 2 } else { 1 }; + center = behavior == 0; + Some(e.g.upload_image(&o.buf.img)?) + } + None => None, + }; + let idx = |s: Src| effectcraft_effects::util::SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32; + let mut p = Params::default(); + p.u[0] = [idx(hs), idx(vs), mode, wrap as u32 | (center as u32) << 1]; + p.u[1] = [w as u32, h as u32, 0, 0]; + p.f[0] = [mh as f32, mv as f32, 0.0, 0.0]; + let buf = e.data(&data); + let out = e.image(b.img.width, b.img.height); + e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize; + let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize; + let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize; + let offs = effectcraft_effects::parse_mesh(ctx.params.s("mesh")); + if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let vert = |i: usize, j: usize| { + let o = offs[j * (cols + 1) + i]; + b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1]) + }; + let (nx, ny) = (cols * q, rows * q); + let mut dest = Vec::with_capacity((nx + 1) * (ny + 1)); + let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1)); + for gj in 0..=ny { + for gi in 0..=nx { + let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1)); + let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64); + let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1)); + let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v; + let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v; + dest.push((x, y)); + srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh])); + } + } + // distort2::grid_warp: triangles in order, bucketed per output row. + let idx = |i: usize, j: usize| j * (nx + 1) + i; + let hh = b.img.height as usize; + let mut tri_data: Vec = Vec::with_capacity(nx * ny * 24); + let mut buckets: Vec> = vec![Vec::new(); hh]; + let mut t = 0u32; + for j in 0..ny { + for i in 0..nx { + let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1)); + for tri in [[a, b2, c], [a, c, d]] { + let [ia, ib, ic] = tri; + let (pa, pb, pc) = (dest[ia], dest[ib], dest[ic]); + let den: f64 = (pb.1 - pc.1) * (pa.0 - pc.0) + (pc.0 - pb.0) * (pa.1 - pc.1); + // Degenerate triangles are skipped per pixel on the CPU; leaving them out of + // the buckets is the same. + if den.abs() < 1e-12 { + continue; + } + let ys = [pa.1, pb.1, pc.1]; + let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize; + let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize; + for bucket in buckets.iter_mut().take(y1.min(hh)).skip(y0) { + bucket.push(t); + } + let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]); + tri_data.extend( + [(pb.1 - pc.1) / den, (pc.0 - pb.0) / den, (pc.1 - pa.1) / den, (pa.0 - pc.0) / den, pc.0, pc.1, sa.0, sa.1, sb.0, sb.1, sc.0, sc.1] + .map(|v| v as f32), + ); + t += 1; + } + } + } + let row_starts = tri_data.len(); + let list_start = row_starts + hh + 1; + let total: usize = buckets.iter().map(Vec::len).sum(); + if list_start + total >= 1 << 24 { + // Indices past 2^24 are not exact in the f32 data buffer. + return None; + } + let mut data = tri_data; + data.reserve(hh + 1 + total); + let mut n = 0usize; + for bucket in &buckets { + data.push(n as f32); + n += bucket.len(); + } + data.push(n as f32); + for bucket in &buckets { + data.extend(bucket.iter().map(|&t| t as f32)); + } + let mut p = Params::default(); + p.u[0] = [row_starts as u32, list_start as u32, 0, 0]; + let buf = e.data(&data); + run(e, "dst_mesh", &p, b, Some(&buf)) +} + +// ---------------------------------------------------------------- misc.rs (Stylize) + +fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hb = ctx.params.f("horizontal").round().max(1.0) as usize; + let vb = ctx.params.f("vertical").round().max(1.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return Some(b); + } + let cw = (w as f64 / hb as f64).max(1.0); + let ch = (h as f64 / vb as f64).max(1.0); + let mut p = Params::default(); + if ctx.params.b("sharpColors") { + let centre = |i: usize, c: f64| (((i as f64 / c).floor() + 0.5) * c) as i64 as f32; + let data: Vec = (0..w).map(|x| centre(x, cw)).chain((0..h).map(|y| centre(y, ch))).collect(); + p.u[0][0] = w as u32; + let buf = e.data(&data); + return run(e, "dst_mosaic_sharp", &p, b, Some(&buf)); + } + let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize); + let tx: Vec = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect(); + let ty: Vec = (0..h).map(|y| ((y as f64 / ch) as usize).min(ny - 1)).collect(); + // Tile bounds: tile i covers the columns with tx == i (tx is non-decreasing). + let bounds = |t: &[usize], n: usize| -> Vec { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() }; + let mut data: Vec = tx.iter().chain(&ty).map(|&v| v as f32).collect(); + data.extend(bounds(&tx, nx)); + data.extend(bounds(&ty, ny)); + p.u[0] = [w as u32, h as u32, nx as u32, ny as u32]; + let buf = e.data(&data); + let rows = e.image(nx as u32, h as u32); + e.pixels("dst_mosaic_rows", &p, &b.img, None, &rows, Some(&buf)); + let tiles = e.image(nx as u32, ny as u32); + e.pixels("dst_mosaic_tiles", &p, &rows, None, &tiles, Some(&buf)); + let out = e.image(b.img.width, b.img.height); + e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn find_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0][0] = ctx.params.f("blend") as f32 / 100.0; + run(e, "dst_findedges", &p, b, None) +} + +fn emboss(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("direction").to_radians(); + let relief = ctx.params.f("relief") * b.scale; + let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief); + let mut p = Params::default(); + p.f[0] = [dx as f32, dy as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0]; + run(e, "dst_emboss", &p, b, None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce2090..b49dbcc6f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_distort; diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl index b3ce34636..a5493621b 100644 --- a/crates/gpu/src/shaders/fx_distort.wgsl +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -1,2 +1,554 @@ // GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in util::remap. + +const DST_TAU: f32 = 6.2831855; +const DST_HALF_PI: f32 = 1.5707964; + +fn dst_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +fn dst_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// ---------------------------------------------------------------- Twirl (distort::twirl) +// f[0] = (cx, cy, radius, angle in radians) +@compute @workgroup_size(16, 16) +fn dst_twirl(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r) { + let t = 1.0 - d / r; + let a = -P.f[0].w * t * t; + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Bulge (distort::bulge) +// f[0] = (cx, cy, rx, ry); f[1] = (height, taper, pin, edge); f[2] = layer rect (x, y, w, h) +@compute @workgroup_size(16, 16) +fn dst_bulge(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let rx = P.f[0].z; + let ry = P.f[0].w; + let dx = (q.x - c.x) / rx; + let dy = (q.y - c.y) / ry; + let d2 = dx * dx + dy * dy; + var s = q; + if (d2 < 1.0) { + var amt = P.f[1].x * pow(1.0 - d2, P.f[1].y) * 0.5; + if (P.f[1].z > 0.5) { + let l = P.f[2]; + let edge = P.f[1].w; + let ex = clamp(min(q.x - l.x - 0.5, l.x + l.z - 0.5 - q.x) / edge, 0.0, 1.0); + let ey = clamp(min(q.y - l.y - 0.5, l.y + l.w - 0.5 - q.y) / edge, 0.0, 1.0); + amt *= min(ex, ey); + } + let k = 1.0 - amt; + s = vec2(c.x + dx * rx * k, c.y + dy * ry * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Wave Warp (distort::wave_warp) + +fn dst_half_circle(u: f32) -> f32 { + let v = 2.0 * u - 1.0; + return sqrt(max(1.0 - v * v, 0.0)); +} + +fn dst_wave_noise(i: f32, seed: u32) -> f32 { + return hash_noise(bitcast(i32(i)), 0x5eedu, seed) * 2.0 - 1.0; +} + +// distort::wave_shape at the phase `cf + ci` in cycles (t / 2π), split into a fraction cf in +// [0, 2) and whole cycles ci so the fraction keeps full precision. +fn dst_wave_shape(kind: u32, cf: f32, ci: f32, seed: u32) -> f32 { + let ph = cf - floor(cf); + switch kind { + case 1u: { + return select(-1.0, 1.0, ph < 0.5); + } + case 2u: { + return 1.0 - 4.0 * abs(ph - 0.5); + } + case 3u: { + return 2.0 * ph - 1.0; + } + case 4u: { + if (ph < 0.5) { + return dst_half_circle(ph * 2.0); + } + return -dst_half_circle(ph * 2.0 - 1.0); + } + case 5u: { + return 2.0 * dst_half_circle(fract(ph * 2.0)) - 1.0; + } + case 6u: { + return 1.0 - 2.0 * dst_half_circle(fract(ph * 2.0)); + } + case 7u: { + return dst_wave_noise(floor(cf * 2.0) + 2.0 * ci, seed); + } + case 8u: { + let u = cf * 2.0; + let i = floor(u); + let f = u - i; + let s = f * f * (3.0 - 2.0 * f); + let n = i + 2.0 * ci; + return dst_wave_noise(n, seed) * (1.0 - s) + dst_wave_noise(n + 1.0, seed) * s; + } + default: { + return sin(ph * DST_TAU); + } + } +} + +// distort::wave_pin. +fn dst_wave_pin(pin: u32, x: f32, y: f32, r: vec4) -> f32 { + let u = clamp((x - r.x - 0.5) / max(r.z - 1.0, 1.0), 0.0, 1.0); + let v = clamp((y - r.y - 0.5) / max(r.w - 1.0, 1.0), 0.0, 1.0); + let horiz = clamp(min(v, 1.0 - v) * 2.0, 0.0, 1.0); + let vert = clamp(min(u, 1.0 - u) * 2.0, 0.0, 1.0); + switch pin { + case 1u: { return min(horiz, vert); } + case 2u: { return min(sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)) * 2.0, 1.0); } + case 3u: { return u; } + case 4u: { return v; } + case 5u: { return 1.0 - u; } + case 6u: { return 1.0 - v; } + case 7u: { return horiz; } + case 8u: { return vert; } + default: { return 1.0; } + } +} + +// u[0] = (wave type, pinning, seed, buffer width); f[0] = (dx, dy, height); f[2] = layer rect +// (x, y, w, h). data: the phase in cycles (along / width + phase / 2π) as per-column and per-row +// (fraction, whole cycles) pairs: fx (w), ix (w), fy (h), iy (h). +@compute @workgroup_size(16, 16) +fn dst_wavewarp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let dx = P.f[0].x; + let dy = P.f[0].y; + let w = P.u[0].w; + let x = u32(p.x); + let y = u32(p.y); + let cf = data[x] + data[2u * w + y]; + let ci = data[w + x] + data[2u * w + u32(out_dims().y) + y]; + let disp = dst_wave_shape(P.u[0].x, cf, ci, P.u[0].z) * P.f[0].z * dst_wave_pin(P.u[0].y, q.x, q.y, P.f[2]); + textureStore(out, p, sample_bilinear(src, q.x + dy * disp, q.y - dx * disp)); +} + +// ---------------------------------------------------------------- Ripple (distort::ripple) +// u[0].x = asymmetric; f[0] = (cx, cy, radius, wave width); f[1] = (wave height, phase mod 2π) +@compute @workgroup_size(16, 16) +fn dst_ripple(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r && d != 0.0) { + let h = P.f[1].x; + let fall = 1.0 - d / r; + let arg = d / P.f[0].w * DST_TAU + P.f[1].y; + let o = sin(arg) * h * fall; + var t = 0.0; + if (P.u[0].x != 0u) { + t = cos(arg) * h * fall * 0.5; + } + let ux = dx / d; + let uy = dy / d; + s = vec2(q.x + ux * o - uy * t, q.y + uy * o + ux * t); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- CC Lens (distort2::cc_lens) +// f[0] = (cx, cy, radius, convergence exponent) +@compute @workgroup_size(16, 16) +fn dst_cclens(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d >= r) { + textureStore(out, p, vec4(0.0)); + return; + } + var s = q; + if (d >= 1e-9) { + let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w); + let k = r * g / d; + s = vec2(c.x + dx * k, c.y + dy * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Turbulent Displace (distort2) + +// distort2::fbm. u[0].w = octave count; f[1].w = fraction of the last octave; f[3].y = falloff. +fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 { + let n = P.u[0].w; + let frac = P.f[1].w; + let falloff = P.f[3].y; + var sum = 0.0; + var norm = 0.0; + var amp = 1.0; + var f = 1.0; + for (var o = 0u; o < n; o++) { + var w = 1.0; + if (o + 1u == n && frac > 0.0) { + w = frac; + } + sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; + norm += amp * w; + amp *= falloff; + f *= 2.0; + } + return sum / max(norm, 1e-6); +} + +// The centred noise `n(u, v, s)` (cross-faded over the evolution cycle when u[1].x is set). +// f[1] = (z, z - cycle, cycle position t). +fn dst_turb_n(u: f32, v: f32, s: u32) -> f32 { + let seed = P.u[0].z + s; + if (P.u[1].x != 0u) { + let t = P.f[1].z; + return dst_fbm(u, v, P.f[1].x, seed) * (1.0 - t) + dst_fbm(u, v, P.f[1].y, seed) * t - 0.5; + } + return dst_fbm(u, v, P.f[1].x, seed) - 0.5; +} + +// u[0] = (displacement kind, pinning, seed, octaves); u[1].x = cycle evolution; +// f[0] = (offset x, offset y, size, amount); f[1] = (z0, z1, t, last-octave fraction); +// f[2] = layer rect (x, y, w, h); f[3] = (edge, falloff) +@compute @workgroup_size(16, 16) +fn dst_turbulent(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let kind = P.u[0].x; + let amount = P.f[0].w; + let u = (q.x - P.f[0].x) / P.f[0].z; + let v = (q.y - P.f[0].y) / P.f[0].z; + var d = vec2(0.0); + switch kind { + case 1u, 2u, 4u, 5u: { + let e = 0.05; + let gx = (dst_turb_n(u + e, v, 0u) - dst_turb_n(u - e, v, 0u)) / (2.0 * e); + let gy = (dst_turb_n(u, v + e, 0u) - dst_turb_n(u, v - e, 0u)) / (2.0 * e); + let k = amount * 0.25; + if (kind == 1u || kind == 4u) { + d = vec2(gx * k, gy * k); + } else { + d = vec2(-gy * k, gx * k); + } + } + case 6u: { + d = vec2(0.0, dst_turb_n(u, v, 17u) * amount); + } + case 7u: { + d = vec2(dst_turb_n(u, v, 0u) * amount, 0.0); + } + case 8u: { + let a = dst_turb_n(u, v, 0u) * amount; + d = vec2(a, a); + } + default: { + d = vec2(dst_turb_n(u, v, 0u) * amount, dst_turb_n(u, v, 17u) * amount); + } + } + let r = P.f[2]; + let edge = P.f[3].x; + let l = clamp((q.x - r.x - 0.5) / edge, 0.0, 1.0); + let rr = clamp((r.x + r.z - 0.5 - q.x) / edge, 0.0, 1.0); + let t = clamp((q.y - r.y - 0.5) / edge, 0.0, 1.0); + let bt = clamp((r.y + r.w - 0.5 - q.y) / edge, 0.0, 1.0); + var k = 1.0; + switch P.u[0].y { + case 1u: { k = min(min(l, rr), min(t, bt)); } + case 2u: { k = min(t, bt); } + case 3u: { k = min(l, rr); } + case 4u: { k = l; } + case 5u: { k = t; } + case 6u: { k = rr; } + case 7u: { k = bt; } + default: {} + } + textureStore(out, p, sample_bilinear(src, q.x + d.x * k, q.y + d.y * k)); +} + +// ---------------------------------------------------------------- Displacement Map (distort2) + +// util::pick for a straight colour (source index in util::SRC_ORDER). +fn dst_pick(s: u32, c: vec3, a: f32) -> f32 { + switch s { + case 0u: { return c.x; } + case 1u: { return c.y; } + case 2u: { return c.z; } + case 3u: { return a; } + case 4u: { return luminance(c); } + case 5u: { return rgb_to_hsl(c).x; } + case 6u: { return rgb_to_hsl(c).z; } + case 7u: { return rgb_to_hsl(c).y; } + case 8u: { return 1.0; } + case 9u: { return 0.5; } + default: { return 0.0; } + } +} + +// u[0] = (horizontal source, vertical source, map mode: 0 self, 1 fitted layer (transparent +// outside), 2 tiled layer (edges repeat), flags: 1 wrap, 2 Center Map bounds); +// u[1] = (buffer width, buffer height); f[0] = (max horizontal, max vertical). +// data: per-column map x, per-row map y (the map layer's buffer pixels), per-column and +// per-row "inside the centred map" flags. aux = the map layer. +@compute @workgroup_size(16, 16) +fn dst_displace(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[1].x; + let h = P.u[1].y; + let x = u32(p.x); + let y = u32(p.y); + let px = textureLoad(src, p, 0); + let flags = P.u[0].w; + if ((flags & 2u) != 0u && (data[w + h + x] < 0.5 || data[2u * w + h + y] < 0.5)) { + textureStore(out, p, px); + return; + } + var m = px; + let mode = P.u[0].z; + if (mode == 1u) { + m = sample_bilinear(aux, data[x], data[w + y]); + } else if (mode == 2u) { + m = sample_bilinear_clamped(aux, data[x], data[w + y]); + } + var c = vec3(0.0); + let a = m.w; + if (a > 1e-6) { + c = m.xyz / a; + } + let al = max(a, 0.0); + var dx = 0.0; + var dy = 0.0; + if (P.u[0].x != 10u) { + dx = (dst_pick(P.u[0].x, c, al) - 0.5) * 2.0 * P.f[0].x; + } + if (P.u[0].y != 10u) { + dy = (dst_pick(P.u[0].y, c, al) - 0.5) * 2.0 * P.f[0].y; + } + if (dx == 0.0 && dy == 0.0) { + textureStore(out, p, px); + return; + } + var sx = f32(p.x) + 0.5 + dx; + var sy = f32(p.y) + 0.5 + dy; + if ((flags & 1u) != 0u) { + let fw = f32(w); + let fh = f32(h); + sx = sx - floor(sx / fw) * fw; + sy = sy - floor(sy / fh) * fh; + textureStore(out, p, sample_bilinear_clamped(src, sx, sy)); + } else { + textureStore(out, p, sample_bilinear(src, sx, sy)); + } +} + +// ---------------------------------------------------------------- Mesh Warp (distort2::grid_warp) +// u[0] = (row starts offset, list offset). data: 12 floats per triangle (the barycentric +// coefficients k11 k12 k21 k22 relative to vertex c, c, then the source points a, b, c), then +// per output row the start of its triangle list (height + 1 entries), then the lists (triangle +// indices in the CPU's bucket order). +@compute @workgroup_size(16, 16) +fn dst_mesh(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let rs = P.u[0].x; + let lo = P.u[0].y; + let i0 = u32(data[rs + u32(p.y)]); + let i1 = u32(data[rs + u32(p.y) + 1u]); + var cur = vec4(0.0); + for (var i = i0; i < i1; i++) { + if (cur.w > 0.0) { + break; + } + let b = u32(data[lo + i]) * 12u; + let ex = q.x - data[b + 4u]; + let ey = q.y - data[b + 5u]; + let l1 = data[b] * ex + data[b + 1u] * ey; + let l2 = data[b + 2u] * ex + data[b + 3u] * ey; + let l3 = 1.0 - l1 - l2; + // The CPU tests against -1e-9 in f64; f32 rounding needs a wider margin so pixels on + // shared edges are not dropped by both triangles. + let e = -1e-5; + if (l1 < e || l2 < e || l3 < e) { + continue; + } + let sx = data[b + 6u] * l1 + data[b + 8u] * l2 + data[b + 10u] * l3; + let sy = data[b + 7u] * l1 + data[b + 9u] * l2 + data[b + 11u] * l3; + cur = sample_bilinear(src, sx, sy); + } + textureStore(out, p, cur); +} + +// ---------------------------------------------------------------- Mosaic (misc::mosaic) +// data: column tile index (w), row tile index (h), tile column bounds (nx + 1), tile row bounds +// (ny + 1); u[0] = (w, h, nx, ny). + +// Row sums of each tile's columns: out is nx × h. +@compute @workgroup_size(16, 16) +fn dst_mosaic_rows(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + var s = vec4(0.0); + for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) { + s += textureLoad(src, vec2(i32(x), p.y), 0); + } + textureStore(out, p, s); +} + +// Tile averages: out is nx × ny (src = the row sums). +@compute @workgroup_size(16, 16) +fn dst_mosaic_tiles(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + let yb = w + h + P.u[0].z + 1u + u32(p.y); + let y0 = u32(data[yb]); + let y1 = u32(data[yb + 1u]); + var s = vec4(0.0); + for (var y = y0; y < y1; y++) { + s += textureLoad(src, vec2(p.x, i32(y)), 0); + } + let n = f32((u32(data[xb + 1u]) - u32(data[xb])) * (y1 - y0)); + textureStore(out, p, s / max(n, 1.0)); +} + +// Each pixel takes its tile's average (src = the tile averages). +@compute @workgroup_size(16, 16) +fn dst_mosaic_out(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let tx = i32(data[u32(p.x)]); + let ty = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, textureLoad(src, vec2(tx, ty), 0)); +} + +// Sharp Colors: data = per-column / per-row source pixel; u[0].x = w. +@compute @workgroup_size(16, 16) +fn dst_mosaic_sharp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let sx = i32(data[u32(p.x)]); + let sy = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, tex_get_clamped(src, sx, sy)); +} + +// ---------------------------------------------------------------- Find Edges / Emboss (misc) + +fn dst_blend_with(orig: vec4, fx: vec4, k: f32) -> vec4 { + return fx + (orig - fx) * k; +} + +fn dst_lum_at(x: i32, y: i32) -> f32 { + return luminance(tex_get_clamped(src, x, y).xyz); +} + +// u[0].x = invert; f[0].x = blend with original. +@compute @workgroup_size(16, 16) +fn dst_findedges(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let x = p.x; + let y = p.y; + let gx = dst_lum_at(x + 1, y - 1) + 2.0 * dst_lum_at(x + 1, y) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x - 1, y) + - dst_lum_at(x - 1, y + 1); + let gy = dst_lum_at(x - 1, y + 1) + 2.0 * dst_lum_at(x, y + 1) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x, y - 1) + - dst_lum_at(x + 1, y - 1); + let e = min(sqrt(gx * gx + gy * gy), 1.0); + var v = 1.0 - e; + if (P.u[0].x != 0u) { + v = e; + } + let px = textureLoad(src, p, 0); + let a = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * a, v * a, v * a, a), P.f[0].x)); +} + +// f[0] = (dx, dy, contrast, blend with original). +@compute @workgroup_size(16, 16) +fn dst_emboss(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let d = P.f[0].xy; + let a = sample_bilinear_clamped(src, q.x + d.x, q.y + d.y); + let c = sample_bilinear_clamped(src, q.x - d.x, q.y - d.y); + let l = luminance(a.xyz) - luminance(c.xyz); + let v = clamp(0.5 + l * P.f[0].z, 0.0, 1.0); + let px = textureLoad(src, p, 0); + let al = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * al, v * al, v * al, al), P.f[0].w)); +} diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs new file mode 100644 index 000000000..9732f99ca --- /dev/null +++ b/crates/gpu/src/tests_fx_distort.rs @@ -0,0 +1,119 @@ +//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +#[test] +fn twirl_bulge_ripple_wave_warp() { + effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]); + effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]); + effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]); + effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]); + effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]); + effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]); + effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); +} + +#[test] +fn turbulent_displace_and_cc_lens() { + effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); + effect_case( + "ec.distort.turbulentdisplace", + &[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())], + ); + effect_case( + "ec.distort.turbulentdisplace", + &[ + ("displacement", e(5)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("evolutionOptions/randomSeed", n(7.0)), + ("pinning", e(0)), + ("amount", n(-30.0)), + ], + ); + effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]); + effect_case("ec.distort.cclens", &[("size", n(70.0))]); + effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]); + effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); +} + +#[test] +fn mesh_warp() { + // 2 rows × 3 columns: 12 vertices. + let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]); + let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]); +} + +#[test] +fn mosaic_find_edges_emboss() { + effect_case("ec.stylize.mosaic", &[]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]); + effect_case("ec.stylize.findedges", &[]); + effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]); + effect_case("ec.stylize.emboss", &[]); + effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]); +} + +#[test] +fn displacement_map_self() { + effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]); + effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]); + effect_case( + "ec.distort.displacementmap", + &[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))], + ); +} + +/// Displacement Map reading another layer (Center / Stretch / Tile), composited. +#[test] +fn displacement_map_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut map = s.footage(50, 36); + map.switches.video = false; + let mid = s.push(map); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.distort.displacementmap", + &[ + ("displacementMapLayer", Value::Layer(Some(mid.0))), + ("useForHorizontal", e(0)), + ("useForVertical", e(4)), + ("maxHorizontal", n(9.0)), + ("maxVertical", n(6.0)), + ("displacementMapBehavior", e(behavior)), + ("wrapPixelsAround", Value::Bool(wrap)), + ], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("displacement map {behavior} {wrap} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} From 0e3b354520ec7b62979f7a0362c7f1485beff9b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:44:36 -0400 Subject: [PATCH 06/20] M12.7: GPU particles (CC Particle World, CC Particle Systems II, Particle Playground cannon): compute-shader simulation with GPU checkpoints, CPU as the oracle Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 8 + crates/effects/src/psim.rs | 106 ++++++++++ crates/effects/src/sim.rs | 88 ++++++++- crates/effects/src/sim3.rs | 76 +++++++- crates/gpu/src/context.rs | 5 + crates/gpu/src/lib.rs | 13 ++ crates/gpu/src/particles.rs | 209 ++++++++++++++++++++ crates/gpu/src/shaders/particles.wgsl | 270 ++++++++++++++++++++++++++ crates/gpu/src/tests_particles.rs | 155 +++++++++++++++ crates/render/src/lib.rs | 8 + 10 files changed, 925 insertions(+), 13 deletions(-) create mode 100644 crates/effects/src/psim.rs create mode 100644 crates/gpu/src/particles.rs create mode 100644 crates/gpu/src/shaders/particles.wgsl create mode 100644 crates/gpu/src/tests_particles.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f3465..8840a3839 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -40,6 +40,7 @@ mod ocio; pub mod paint; mod perspective; mod perspective2; +pub mod psim; pub mod puppet; pub mod roto; mod sim; @@ -67,6 +68,8 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use sim::particle_state; +pub use sim3::playground_state; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -260,6 +263,11 @@ pub trait EffectHost: Sync { fn aux(&self) -> Option> { None } + /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the + /// CPU. + fn particles(&self) -> Option<&dyn psim::ParticleSim> { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs new file mode 100644 index 000000000..aabbcdce8 --- /dev/null +++ b/crates/effects/src/psim.rs @@ -0,0 +1,106 @@ +//! Pluggable particle simulation (GPU particles, M12.7). +//! +//! The stepped particle systems (CC Particle World, CC Particle Systems II and Particle +//! Playground's Cannon without interacting forces) evolve every particle independently: births +//! come from a rate (the same `carry` arithmetic as the CPU step), and each particle integrates +//! its own motion with the same fixed step. An accelerator ([`ParticleSim`], reached through +//! [`crate::EffectHost::particles`]) can therefore simulate all of them in parallel and return +//! the state the CPU's `SimCache` would reach. The CPU stays the oracle: anything the backend +//! declines (interacting forces, caps that would drop births) simulates on the CPU. + +/// CC Particle World / CC Particle Systems II physics (see `sim::Phys`), with the per-step and +/// per-spawn constants already evaluated on the CPU (bit-identical to the CPU's). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EngineDesc { + pub life: f32, + pub producer: [f32; 3], + pub radius: [f32; 3], + /// Animation (0 Explosive … 9 Fractal Uni). + pub anim: u32, + pub speed: f32, + pub gravity: [f32; 3], + /// Normalised direction axis. + pub axis: [f32; 3], + pub extra: f32, + /// Cone Axis spread factor `|tan(extra angle / 2)|` (≤ 20). + pub cone: f32, + /// Velocity damping per step `exp(−drag · dt)`. + pub damp: f32, + pub seed: u32, + /// 2D systems keep z = 0. + pub flat: bool, +} + +/// Particle Playground's Cannon with gravity only (see `sim3::Pg`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CannonDesc { + pub pos: [f32; 2], + pub barrel_angle: f32, + pub barrel_radius: f32, + pub dir_spread: f32, + pub vel: f32, + pub vel_spread: f32, + pub gravity: [f32; 2], + pub grav_spread: f32, + pub seed: u32, + /// Particles leaving `[x0, y0, x1, y1]` are removed. + pub bounds: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ParticleSystem { + Engine(EngineDesc), + Cannon(CannonDesc), +} + +/// One simulation request: the state after `steps` steps of system `key`. +#[derive(Clone, Debug)] +pub struct SimRequest<'a> { + /// Identifies the system (parameters, seed); checkpoints are reused per key. + pub key: u64, + pub steps: u64, + /// Birth step of every particle id born before `steps` (ascending). + pub births: &'a [u32], + pub system: ParticleSystem, +} + +/// A live particle (ascending ids, as the CPU state keeps them). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SimParticle { + pub p: [f32; 3], + pub v: [f32; 3], + pub age: f32, + pub life: f32, + pub rnd: f32, + pub id: u32, +} + +/// A particle simulation backend (the GPU). +pub trait ParticleSim: Send + Sync { + /// The live particles after `req.steps` steps, or `None` (simulate on the CPU). + fn simulate(&self, req: &SimRequest) -> Option>; +} + +/// Birth step of every particle born in the first `steps` steps at `rate` particles per second +/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the +/// CPU drops births at its live-particle cap, which an independent simulation can't know). +pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { + let per = rate / SPS; + if per * steps as f64 > cap as f64 + 2.0 { + return None; + } + let mut out = Vec::new(); + let mut carry = 0.0f64; + for i in 0..steps { + carry += per; + let n = carry.floor() as u32; + carry -= n as f64; + for _ in 0..n { + out.push(i as u32); + } + } + (out.len() <= cap).then_some(out) +} + +/// Steps per second of the stepped simulations. +pub const SPS: f64 = crate::sim::SPS; diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 7f90af6b1..18beeb762 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -818,6 +818,60 @@ impl Phys { } } +impl Phys { + /// The backend description (per-step and per-spawn constants evaluated here, in f32). + pub(crate) fn desc(&self) -> crate::psim::EngineDesc { + let mut drag = self.resistance; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let dt = (1.0 / SPS) as f32; + crate::psim::EngineDesc { + life: self.life, + producer: self.producer, + radius: self.radius, + anim: self.anim, + speed: self.speed, + gravity: self.gravity, + axis: norm3(self.axis), + extra: self.extra, + cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0), + damp: (-drag * dt).exp(), + seed: self.seed, + flat: self.flat, + } + } +} + +/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or +/// `None` (simulate on the CPU). +fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(phys.rate, steps, phys.max)?; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PState { + parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + +/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, +/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has +/// one (GPU particles), else from the CPU simulation. For comparing the two. +pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { + let (phys, cache, salt) = match id { + "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), + "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + _ => return None, + }; + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); + Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) +} + /// Run a particle system to layer time `t` through `cache`. pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) @@ -859,10 +913,10 @@ fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, static PW_CACHE: SimCache = SimCache::new(6); -fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle World's physics. +fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 120.0, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], @@ -882,11 +936,18 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, max: 40_000, flat: false, - }; + } +} + +fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let phys = pw_phys(ctx); if phys.rate <= 0.0 && ctx.time <= 0.0 { return b; } - let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &phys); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -936,13 +997,14 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { static PS2_CACHE: SimCache = SimCache::new(6); -fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle Systems II's physics. +fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 60.0, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], @@ -958,8 +1020,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, max: 40_000, flat: true, - }; - let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &phys); + } +} + +fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let phys = ps2_phys(ctx); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 5560917a1..f7425df7c 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -231,6 +231,38 @@ impl Pg { static PG_CACHE: SimCache = SimCache::new(6); +/// Particle Playground cannons with gravity only, simulated by the host's particle backend. +fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(pg.rate, steps, 60_000)?; + let desc = crate::psim::CannonDesc { + pos: pg.pos, + barrel_angle: pg.barrel_angle, + barrel_radius: pg.barrel_radius, + dir_spread: pg.dir_spread, + vel: pg.vel, + vel_spread: pg.vel_spread, + gravity: pg.gravity, + grav_spread: pg.grav_spread, + seed: pg.seed, + bounds: pg.bounds, + }; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Cannon(desc) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PgState { + parts: parts + .into_iter() + .map(|q| { + let born = pg.spawn(q.id); + PgParticle { p: [q.p[0], q.p[1]], v: [q.v[0], q.v[1]], ..born } + }) + .collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + fn image_key(img: &Image) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -244,14 +276,15 @@ fn image_key(img: &Image) -> u64 { h.finish() } -fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Particle Playground's producers and forces. +fn pg_of(ctx: &EffectCtx) -> Pg { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); let gf = pr.f("gravity/gravityForce") as f32; let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; - let pg = Pg { + Pg { cannon: pr.b("cannonEnabled"), pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, barrel_angle: pr.f("cannon/barrelAngle") as f32, @@ -275,7 +308,34 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { ], seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], - }; + } +} + +/// The live particles of a Particle Playground whose cannon is the only producer and force +/// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU +/// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two. +pub fn playground_state(ctx: &EffectCtx) -> Option> { + let pg = pg_of(ctx); + let pr = ctx.params; + let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0; + let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some(); + if !pg.cannon || grid || exploder || (pg.repel != 0.0 && pg.repel_radius > 0.0) || pg.wall.is_some() || pg.mapper.is_some() { + return None; + } + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); + let st = pg_accelerated(ctx, key, &pg).unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), PgState::default, |st, _| pg.step(st))); + Some( + st.parts + .iter() + .map(|q| crate::psim::SimParticle { p: [q.p[0], q.p[1], 0.0], v: [q.v[0], q.v[1], 0.0], age: 0.0, life: 0.0, rnd: 0.0, id: q.id }) + .collect(), + ) +} + +fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let pg = pg_of(ctx); // Initial particles: Grid and Layer Exploder. let grid_on = pr.b("gridEnabled"); let across = pr.f("grid/particlesAcross").max(0.0).round() as u32; @@ -347,7 +407,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { } st }; - let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + // A cannon with gravity only: every particle moves on its own (GPU particles). + let independent = pg.cannon + && !(grid_on && across > 0 && down > 0) + && exploder.is_none() + && !(pg.repel != 0.0 && pg.repel_radius > 0.0) + && pg.wall.is_none() + && pg.mapper.is_none(); + let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None }; + let st = gpu.unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st))); let s = b.scale as f32; let sprites: Vec = st .parts diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e7cda93e4..7dd14ba83 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -102,6 +102,9 @@ pub struct GpuContext { uploads: Mutex, /// Advanced 3D render pipelines (built on first use). pub(crate) adv3d: std::sync::OnceLock, + /// GPU particle pipeline (built on first use) and simulation checkpoints. + pub(crate) particles: crate::particles::PipesCell, + pub(crate) particle_states: crate::particles::StatesCell, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -263,6 +266,8 @@ impl GpuContext { dummy_buf, uploads: Mutex::new(Uploads::default()), adv3d: std::sync::OnceLock::new(), + particles: Default::default(), + particle_states: Default::default(), }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce2090..83f27b26d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -29,6 +29,7 @@ mod fx_color; mod fx_distort; mod fx_generate; mod ops; +mod particles; mod walk; use std::sync::Arc; @@ -139,6 +140,16 @@ impl Accelerator for Gpu { fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { adv3d::render(&self.ctx, scene) } + + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim) + } +} + +impl effectcraft_effects::psim::ParticleSim for Gpu { + fn simulate(&self, req: &effectcraft_effects::psim::SimRequest) -> Option> { + particles::simulate(&self.ctx, req) + } } #[cfg(test)] @@ -149,3 +160,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_particles; diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs new file mode 100644 index 000000000..669a4ba05 --- /dev/null +++ b/crates/gpu/src/particles.rs @@ -0,0 +1,209 @@ +//! GPU particles (`particles.wgsl`): the stepped particle systems of `effectcraft-effects` +//! ([`effectcraft_effects::psim`]) simulated with one invocation per particle. States stay on +//! the GPU as checkpoints (per system key, like the CPU's `SimCache`), so playback advances from +//! the previous frame's state instead of simulating from layer time 0. + +use std::sync::{Mutex, OnceLock}; + +use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; +use wgpu::util::DeviceExt; + +use crate::context::GpuContext; + +/// Bytes of one particle state (`PState` in the shader). +const STATE_BYTES: u64 = 48; +/// Checkpoints kept (all keys). +const CHECKPOINTS: usize = 24; + +pub(crate) struct Pipes { + bgl: wgpu::BindGroupLayout, + pipe: wgpu::ComputePipeline, +} + +struct Checkpoint { + key: u64, + steps: u64, + n: u32, + buf: wgpu::Buffer, + used: u64, +} + +#[derive(Default)] +pub(crate) struct Checkpoints { + list: Vec, + clock: u64, +} + +fn pipes(g: &GpuContext) -> &Pipes { + g.particles.get_or_init(|| { + let d = &g.device; + let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/particles.wgsl")].concat(); + let module = + d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft particles"), source: wgpu::ShaderSource::Wgsl(src.into()) }); + let buffer = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft particles"), + entries: &[ + buffer(0, wgpu::BufferBindingType::Uniform), + buffer(1, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(2, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(3, wgpu::BufferBindingType::Storage { read_only: false }), + ], + }); + let layout = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft particles"), + bind_group_layouts: &[None, Some(&bgl)], + immediate_size: 0, + }); + let pipe = d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("particles"), + layout: Some(&layout), + module: &module, + entry_point: Some("particles"), + compilation_options: Default::default(), + cache: None, + }); + Pipes { bgl, pipe } + }) +} + +/// The shader's parameter block. +fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { + let dt = (1.0 / effectcraft_effects::psim::SPS) as f32; + let mut u = [n, s0 as u32, req.steps as u32, 0]; + let u2; + let mut f = [[0.0f32; 4]; 6]; + match &req.system { + ParticleSystem::Engine(e) => { + u2 = [e.anim, e.flat as u32, e.seed, prev_n]; + f[0] = [e.life, e.speed, e.extra, e.cone]; + f[1] = [e.producer[0], e.producer[1], e.producer[2], 0.0]; + f[2] = [e.radius[0], e.radius[1], e.radius[2], 0.0]; + f[3] = [e.gravity[0], e.gravity[1], e.gravity[2], 0.0]; + f[4] = [e.axis[0], e.axis[1], e.axis[2], 0.0]; + f[5] = [e.damp, dt, 0.0, 0.0]; + } + ParticleSystem::Cannon(c) => { + u[3] = 1; + u2 = [0, 1, c.seed, prev_n]; + f[0] = [c.pos[0], c.pos[1], c.barrel_angle, c.barrel_radius]; + f[1] = [c.dir_spread, c.vel, c.vel_spread, c.grav_spread]; + f[2] = [c.gravity[0], c.gravity[1], dt, 0.0]; + f[3] = c.bounds; + } + } + let mut out = Vec::with_capacity(128); + for x in u.iter().chain(&u2) { + out.extend_from_slice(&x.to_le_bytes()); + } + for x in f.iter().flatten() { + out.extend_from_slice(&x.to_le_bytes()); + } + out +} + +/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). +pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option> { + if !g.can_readback() || req.steps > u32::MAX as u64 { + return None; + } + let n = req.births.len() as u32; + if n == 0 { + return Some(vec![]); + } + let size = n as u64 * STATE_BYTES; + if size > g.device.limits().max_storage_buffer_binding_size { + return None; + } + let p = pipes(g); + // The latest checkpoint at or before the target step. + let (s0, prev, prev_n) = { + let mut c = g.particle_states.lock().ok()?; + c.clock += 1; + let now = c.clock; + match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps).max_by_key(|k| k.steps) { + Some(k) => { + k.used = now; + (k.steps, Some(k.buf.clone()), k.n) + } + None => (0, None, 0), + } + }; + let d = &g.device; + let births: Vec = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); + let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let prev = prev.unwrap_or_else(|| { + d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) + }); + let next = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles"), + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particles"), + contents: &uniforms(req, n, s0, prev_n), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bg = d.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &p.bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: births.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: prev.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: next.as_entire_binding() }, + ], + }); + let read = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles readback"), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("particles") }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("particles"), timestamp_writes: None }); + pass.set_pipeline(&p.pipe); + pass.set_bind_group(1, &bg, &[]); + pass.dispatch_workgroups(n.div_ceil(64), 1, 1); + } + enc.copy_buffer_to_buffer(&next, 0, &read, 0, size); + g.queue.submit([enc.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + read.map_async(wgpu::MapMode::Read, .., move |r| { + let _ = tx.send(r); + }); + g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; + rx.recv().ok()?.ok()?; + let view = read.get_mapped_range(..).ok()?; + let f: Vec = view.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); + drop(view); + read.unmap(); + let parts = f + .chunks_exact(12) + .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 }) + .collect(); + // Keep the new state as a checkpoint. + if let Ok(mut c) = g.particle_states.lock() { + let now = c.clock; + c.list.retain(|k| !(k.key == req.key && k.steps == req.steps)); + c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now }); + while c.list.len() > CHECKPOINTS { + let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; + c.list.remove(i); + } + } + Some(parts) +} + +pub(crate) type PipesCell = OnceLock; +pub(crate) type StatesCell = Mutex; diff --git a/crates/gpu/src/shaders/particles.wgsl b/crates/gpu/src/shaders/particles.wgsl new file mode 100644 index 000000000..694d6fb67 --- /dev/null +++ b/crates/gpu/src/shaders/particles.wgsl @@ -0,0 +1,270 @@ +// GPU particles: effects::psim systems, one invocation per particle id. Mirrors the CPU step +// (effects::sim::Phys::spawn / step, effects::sim3::Pg::spawn / step) operation for operation. +// +// Concatenated after common.wgsl and kernels.wgsl (hash_noise, value_noise, powz); bindings in +// group 1 (group 0 is unused here). + +struct PSim { + // Particles, from step, to step, system (0 engine, 1 cannon). + u: vec4, + // Animation, flat, seed, particles in `prev`. + u2: vec4, + // Engine: f[0] = (life, speed, extra, cone), f[1] = producer, f[2] = radius, + // f[3] = gravity, f[4] = axis, f[5] = (damp, dt). + // Cannon: f[0] = (pos.x, pos.y, barrel angle, barrel radius), f[1] = (dir spread, vel, + // vel spread, grav spread), f[2] = (gravity.x, gravity.y, dt), f[3] = bounds. + f: array, 6>, +} + +struct PState { + // Position, age. + p: vec4, + // Velocity, life. + v: vec4, + // rnd, alive (1 / 0), id. + m: vec4, +} + +@group(1) @binding(0) var S: PSim; +@group(1) @binding(1) var births: array; +@group(1) @binding(2) var prev: array; +@group(1) @binding(3) var next: array; + +const PS_TAU: f32 = 6.2831855; + +fn ps_h(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z); +} + +fn ps_hs(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z) * 2.0 - 1.0; +} + +// sim::norm3. +fn ps_norm3(v: vec3) -> vec3 { + let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (l > 1e-9) { + return v / l; + } + return vec3(0.0, -1.0, 0.0); +} + +// sim::rand_dir. +fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3 { + if (flat) { + let a = ps_h(i, k) * PS_TAU; + return vec3(cos(a), sin(a), 0.0); + } + let z = ps_hs(i, k); + let a = ps_h(i, k + 1u) * PS_TAU; + let r = sqrt(max(1.0 - z * z, 0.0)); + return vec3(r * cos(a), r * sin(a), z); +} + +struct Part { + p: vec3, + v: vec3, + age: f32, + life: f32, + rnd: f32, +} + +// sim::Phys::spawn (+ the step's sub-step birth offset). +fn ps_spawn_engine(id: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let producer = S.f[1].xyz; + let radius = S.f[2].xyz; + let u = ps_rand_dir(id, 1u, flat); + var e = 1.0 / 3.0; + if (flat) { + e = 0.5; + } + let rr = powz(ps_h(id, 3u), e); + var p = producer + u * radius * rr; + if (flat) { + p.z = 0.0; + } + let ax = S.f[4].xyz; + let jitter = ps_rand_dir(id, 5u, flat); + var dir = jitter; + switch anim { + case 1u, 9u: { + dir = ps_norm3(ax + jitter * 0.08); + } + case 2u: { + let k = S.f[0].w; + dir = ps_norm3(ax + jitter * k * ps_h(id, 8u)); + } + case 5u: { + var z = jitter.z; + if (flat) { + z = 0.0; + } + dir = vec3(jitter.x, 0.0, z); + } + case 6u: { + dir = ps_norm3(vec3(jitter.x * 0.25, -1.0, jitter.z * 0.25)); + } + case 7u: { + dir = ps_norm3(vec3(1.0, jitter.y * 0.05, jitter.z * 0.05)); + } + default: {} + } + let sp = S.f[0].y * (0.5 + 0.5 * ps_h(id, 9u)); + var v = dir * sp; + if (flat) { + v.z = 0.0; + } + let life = S.f[0].x * (0.8 + 0.4 * ps_h(id, 10u)); + let dt = S.f[5].y; + let frac = ps_h(id, 12u) * dt; + return Part(p + v * frac, v, frac, life, ps_h(id, 11u)); +} + +// sim::Phys::step for one particle at step `j`. +fn ps_step_engine(q: Part, j: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let seed = S.u2.z; + let dt = S.f[5].y; + let t = f32(j) * dt; + let producer = S.f[1].xyz; + let gravity = S.f[3].xyz; + let extra = S.f[0].z; + var a = gravity; + switch anim { + case 4u: { + let dx = q.p.x - producer.x; + let dz = q.p.z - producer.z; + let k = 4.0 * extra; + a.x += -dz * k; + a.z += dx * k; + if (flat) { + let dy = q.p.y - producer.y; + a.x += -dy * k; + a.y += dx * k; + } + } + case 5u: { + let dx = q.p.x - producer.x; + let dy = q.p.y - producer.y; + let k = 6.0 * extra; + a.x += -dy * k - dx * 2.0; + a.y += dx * k - dy * 2.0; + } + case 6u: { + let n = value_noise(q.p.x * 8.0, q.p.y * 8.0, t * 3.0, seed) - 0.5; + a.x += n * 4.0 * extra; + a.y -= gravity.y * 1.5 + 0.3 * extra; + } + case 8u, 9u: { + let f = 6.0 * extra; + a.x += (value_noise(q.p.x * 5.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + a.y += (value_noise(q.p.x * 5.0 + 17.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + if (!flat) { + a.z += (value_noise(q.p.x * 5.0, q.p.y * 5.0 + 31.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + } + } + default: {} + } + let damp = S.f[5].x; + var o = q; + o.v = (q.v + a * dt) * damp; + o.p = q.p + o.v * dt; + if (flat) { + o.p.z = 0.0; + o.v.z = 0.0; + } + o.age = q.age + dt; + return o; +} + +// sim3::Pg::spawn (+ the sub-step birth offset). +fn ps_spawn_cannon(id: u32) -> Part { + let pos = S.f[0].xy; + let angle = S.f[0].z; + let r = S.f[0].w; + let spread = S.f[1].x; + let vel = S.f[1].y; + let vel_spread = S.f[1].z; + let a = radians(angle + ps_hs(id, 1u) * spread * 0.5); + let d = vec2(sin(a), -cos(a)); + let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0); + var off: vec2; + if (r >= 0.0) { + off = vec2(ps_hs(id, 2u) * r, ps_hs(id, 4u) * r); + } else { + let aa = ps_h(id, 5u) * PS_TAU; + let rr = sqrt(ps_h(id, 6u)) * -r; + off = vec2(cos(aa) * rr, sin(aa) * rr); + } + let v = d * speed; + let dt = S.f[2].z; + let frac = ps_h(id, 9u) * dt; + let p = pos + off + v * frac; + return Part(vec3(p, 0.0), vec3(v, 0.0), 0.0, 0.0, 0.0); +} + +// sim3::Pg::step for one particle (gravity only); returns false when it leaves the bounds. +fn ps_step_cannon(q: ptr, id: u32) -> bool { + let dt = S.f[2].z; + let spread = S.f[1].w; + var g = 1.0; + if (spread > 0.0) { + g = 1.0 + (ps_h(id, 7u) * 2.0 - 1.0) * spread; + } + let a = S.f[2].xy * g; + let v = ((*q).v.xy + a * dt) * 1.0; + let p = (*q).p.xy + v * dt; + (*q).v = vec3(v, 0.0); + (*q).p = vec3(p, 0.0); + let b = S.f[3]; + return p.x > b.x && p.x < b.z && p.y > b.y && p.y < b.w; +} + +@compute @workgroup_size(64, 1) +fn particles(@builtin(global_invocation_id) gid: vec3) { + let id = gid.x; + if (id >= S.u.x) { + return; + } + let s0 = S.u.y; + let s1 = S.u.z; + let cannon = S.u.w == 1u; + let b = births[id]; + var q: Part; + var j: u32; + if (b < s0 && id < S.u2.w) { + let o = prev[id]; + if (o.m.y == 0.0) { + next[id] = o; + return; + } + q = Part(o.p.xyz, o.v.xyz, o.p.w, o.v.w, o.m.x); + j = s0; + } else { + if (cannon) { + q = ps_spawn_cannon(id); + } else { + q = ps_spawn_engine(id); + } + j = b + 1u; + } + var alive = true; + for (; j < s1; j++) { + if (cannon) { + if (!ps_step_cannon(&q, id)) { + alive = false; + break; + } + } else { + q = ps_step_engine(q, j); + if (q.age >= q.life) { + alive = false; + break; + } + } + } + next[id] = PState(vec4(q.p, q.age), vec4(q.v, q.life), vec4(q.rnd, select(0.0, 1.0, alive), 0.0, 0.0)); +} diff --git a/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs new file mode 100644 index 000000000..e3ef97a82 --- /dev/null +++ b/crates/gpu/src/tests_particles.rs @@ -0,0 +1,155 @@ +//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II +//! and Particle Playground's cannon at fixed seeds must give the same live particles (same +//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU +//! checkpoints. + +use effectcraft_effects::psim::{ParticleSim, SimParticle}; +use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; +use effectcraft_keyframe::Value; + +use crate::Gpu; +use crate::tests::{gpu, n}; + +struct Host(&'static Gpu); + +impl EffectHost for Host { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + Some(self.0) + } +} + +fn params(id: &str, vals: &[(&str, Value)]) -> Params { + let spec = effectcraft_effects::find(id).unwrap(); + let size = [320.0, 240.0]; + let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + assert!(p.values.contains_key(*k), "{id}: no {k}"); + p.values.insert(k.to_string(), v.clone()); + } + p +} + +/// (CPU, GPU) particles of effect `id` at layer time `t`. +fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec, Vec) { + let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + let run = + |c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) }; + let cpu = run(&ctx(EffectEnv::default())).expect("cpu state"); + let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state"); + (cpu, gpu) +} + +/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol` +/// (world units) for 99 % of them and on average within `tol / 10`. +fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) { + assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len()); + let gi: std::collections::HashMap = gpu.iter().map(|q| (q.id, q)).collect(); + let mut missing = 0; + let mut errs = vec![]; + for q in cpu { + match gi.get(&q.id) { + Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)), + None => missing += 1, + } + } + let extra = gpu.len() + missing - cpu.len(); + assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len()); + errs.sort_by(f32::total_cmp); + let mean = errs.iter().sum::() / errs.len() as f32; + let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)]; + eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap()); + assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})"); +} + +#[test] +fn particle_world_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let p = params( + "ec.sim.ccparticleworld", + &[ + ("physics/animation", Value::Enum(anim)), + ("birthRate", n(2.0)), + ("longevity", n(1.2)), + ("physics/extra", n(0.6)), + ("physics/resistance", n(0.5)), + ("extras/randomSeed", n(7.0)), + ], + ); + let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host); + // Fractal / fire animations sample value noise along the path: rounding differences can + // grow, so they get a looser bound. + let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 }; + agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn particle_systems_ii_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 4, 5, 8] { + let p = params( + "ec.sim.ccparticlesystems2", + &[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))], + ); + let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host); + let tol = if anim == 8 { 1e-3 } else { 1e-4 }; + agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] { + let p = params( + "ec.sim.particleplayground", + &[ + ("grid/particlesAcross", n(0.0)), + ("cannon/barrelRadius", n(radius)), + ("cannon/particlesPerSecond", n(90.0)), + ("cannon/directionRandomSpread", n(30.0)), + ("cannon/velocityRandomSpread", n(40.0)), + ("gravity/gravityForceRandomSpread", n(spread)), + ], + ); + // Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each. + for t in [0.5, 1.0, 2.0, 1.5] { + let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host); + // Positions are layer pixels here. + agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1); + } + } +} + +/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and +/// the composited frames agree with the CPU render. +#[test] +fn particle_effects_render_like_the_cpu() { + use effectcraft_project::BitDepth; + use effectcraft_time::Tick; + + use crate::tests::{Scene, check, compare_at, opts}; + for (id, vals) in [ + ("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]), + ("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]), + ("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]), + ] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.solid([0.1, 0.1, 0.15], 97, 61); + s.effect(&mut l, id, &vals); + s.push(l); + check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ffb559369..44f4876a0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -82,6 +82,9 @@ struct FxHost<'r, 'a, 'c> { const MAX_FX_DEPTH: usize = 8; impl EffectHost for FxHost<'_, '_, '_> { + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.r.active_accel().and_then(|a| a.particles()) + } fn layer(&self, id: u64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { @@ -240,6 +243,11 @@ pub trait Accelerator: Send + Sync { fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option { None } + /// A particle simulation backend (GPU particles) for the stepped particle effects, with + /// the CPU simulation's semantics. `None` = they simulate on the CPU. + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + None + } } /// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image From f855a4b55bfe13000698862ae0d74cc8aed251a7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:45:44 -0400 Subject: [PATCH 07/20] M14.5: wire the remaining settings; keyframe assistants and labels; AE menu entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: 56 of 59 not-yet-wired settings now change behaviour (nested-comp switches, constant vertex count, related-item time sync, match-name pick whip, split-layer order, Home on open, motion path span, internal wireframes, zoom quality, label-coloured tabs, mask colour cycling, gradients, grid/guide styles, text engine, font preview/recent/native names, missing frames, unlabeled alpha, drag import, default output folder, segmenting, bit depth names, preview sample rate, disk/media/conformed-audio caches, RAM reserve and low-memory reduction, Video Preview window, 3D reference axes/extended viewer/draft shadows, script file gate + File.execute warning, expression editor options and error banner). Three stay display-only and are documented. Features: Convert Audio to Keyframes, RPF Camera Import (JSON/CSV camera data), keyframe colour labels + Select Keyframe Label Group, Recent Animation Presets. Menus: Edit > History, File > Import Recent Footage, Replace Footage > With Layered Comp, Save a Copy As XML (.ecprojx, opens again), Composition > VR (Create VR Environment, Extract Cubemap), Assign Shortcut to 3D view / workspace, Window viewer labels + open viewers, Help > System Compatibility Report / Enable Logging / Reveal Logging File. Dynamic menu nodes (@dynamic:…) shared by the egui and native menus. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/audio_out.rs | 10 +- apps/effectcraft/src/main.rs | 7 +- crates/engine/src/commands/animation.rs | 6 + crates/engine/src/commands/app_more.rs | 592 +++++++++++++++++ crates/engine/src/commands/edit.rs | 11 +- crates/engine/src/commands/file.rs | 78 ++- crates/engine/src/commands/keys_more.rs | 298 +++++++++ crates/engine/src/commands/layer.rs | 8 +- crates/engine/src/commands/layer_menu.rs | 51 +- crates/engine/src/commands/link.rs | 13 +- crates/engine/src/commands/mask.rs | 96 ++- crates/engine/src/commands/mod.rs | 11 + crates/engine/src/commands/prop.rs | 2 +- crates/engine/src/commands/render_queue.rs | 11 +- crates/engine/src/commands/stubs.rs | 3 - crates/engine/src/lib.rs | 117 ++++ crates/engine/src/logging.rs | 99 +++ crates/engine/src/media_cache.rs | 65 ++ crates/engine/src/menus.rs | 167 ++++- crates/engine/src/menus/tests.rs | 2 +- crates/engine/src/prefs.rs | 303 +++++++-- crates/engine/src/render_queue.rs | 130 +++- crates/engine/src/session_settings.rs | 57 +- crates/engine/src/sysinfo.rs | 140 ++++ crates/engine/src/tests_m145.rs | 666 +++++++++++++++++++ crates/engine/src/xml_project.rs | 265 ++++++++ crates/export/src/lib.rs | 4 + crates/export/tests/roundtrip.rs | 8 +- crates/host/src/lib.rs | 1 + crates/keyframe/src/lib.rs | 10 + crates/media/src/pool.rs | 108 ++- crates/render/src/lib.rs | 69 +- crates/render/src/three_d/adv/scene.rs | 9 +- crates/render/src/three_d/compose.rs | 8 +- crates/script/src/prelude.js | 2 +- crates/script/src/runtime.rs | 5 + crates/text/src/fonts.rs | 10 +- crates/text/src/sfnt.rs | 27 +- crates/ui-egui/src/audio.rs | 45 +- crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/dock_ui.rs | 25 + crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/menus.rs | 96 ++- crates/ui-egui/src/native_menu.rs | 65 +- crates/ui-egui/src/panels/expr_editor.rs | 394 +++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 86 ++- crates/ui-egui/src/panels/timeline.rs | 29 +- crates/ui-egui/src/panels/viewer.rs | 101 ++- crates/ui-egui/src/panels/viewer_overlays.rs | 7 +- crates/ui-egui/src/panels/viewer_tools.rs | 12 +- crates/ui-egui/src/panels/waveform.rs | 17 +- crates/ui-egui/src/prefs_live.rs | 141 ++++ crates/ui-egui/src/theme.rs | 25 + crates/ui-egui/tests/ui_text_edit.rs | 4 +- docs/preferences.md | 132 ++-- 56 files changed, 4378 insertions(+), 284 deletions(-) create mode 100644 crates/engine/src/commands/app_more.rs create mode 100644 crates/engine/src/commands/keys_more.rs create mode 100644 crates/engine/src/logging.rs create mode 100644 crates/engine/src/media_cache.rs create mode 100644 crates/engine/src/sysinfo.rs create mode 100644 crates/engine/src/tests_m145.rs create mode 100644 crates/engine/src/xml_project.rs create mode 100644 crates/ui-egui/src/panels/expr_editor.rs create mode 100644 crates/ui-egui/src/prefs_live.rs diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index eea7ffaa5..06ab9785e 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -47,7 +47,15 @@ fn device(name: &str) -> Option { pub fn open(out: &AudioOutput) -> Option> { let dev = device(&out.device)?; let cfg = dev.default_output_config().ok()?; - Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) + // Settings ▸ Audio ▸ Preview Sample Rate: open the device at that rate when it supports it + // (otherwise the preview is mixed at that rate and resampled to the device's). + let supports = |r: u32| { + dev.supported_output_configs() + .map(|mut c| c.any(|c| c.channels() == cfg.channels() && c.min_sample_rate().0 <= r && r <= c.max_sample_rate().0)) + .unwrap_or(false) + }; + let rate = if out.rate > 0 && supports(out.rate) { out.rate } else { cfg.sample_rate().0 }; + Some(Box::new(CpalOut { out: out.clone(), rate, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) } fn run>( diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index b6426f5f0..157ffba27 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -24,6 +24,8 @@ fn app_icon() -> egui::IconData { } fn main() -> eframe::Result { + // Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report. + effectcraft_engine::logging::install(); let mut control_port: Option = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut demo = true; @@ -94,8 +96,9 @@ fn main() -> eframe::Result { app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); - app.hooks.pick_open_project = - Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) + })); app.hooks.audio_device = Some(Box::new(audio_out::open)); app.hooks.audio_devices = Some(Box::new(audio_out::devices)); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index a6390504b..aa74c3f2f 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -103,6 +103,12 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("anim.applyPreset", "select a layer")); } + // Animation ▸ Recent Animation Presets. + if let Some(path) = str_p(p, "path") { + s.prefs.push_recent_preset(path); + s.save_prefs(); + s.prefs_revision += 1; + } let effects: Vec = doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); let props: Vec<(String, Property)> = doc["properties"] diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs new file mode 100644 index 000000000..37604853c --- /dev/null +++ b/crates/engine/src/commands/app_more.rs @@ -0,0 +1,592 @@ +//! Menu entries of M14.5: Edit ▸ History, File ▸ Import Recent Footage, Replace Footage ▸ With +//! Layered Comp, Save a Copy As XML, Animation ▸ Recent Animation Presets, Composition ▸ VR +//! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable +//! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts. + +use effectcraft_geom::{Mat4, Vec3, vec3}; +use effectcraft_project::{ItemId, ItemKind, LayerSource}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p}; +use crate::{EngineError, Event, Result, Session, cmd, query}; + +/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error +/// the project is restored. +pub(crate) fn grouped(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + // The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot + // taken before them), then become one step on the real one. + let saved = std::mem::take(&mut s.history.undo); + let before = s.project.clone(); + let r = f(s); + let changed = !s.history.undo.is_empty(); + s.history.undo = saved; + s.history.merge_key = None; + match r { + Ok(v) => { + if changed { + s.history.undo.push((label.to_string(), before)); + let levels = s.prefs.general.undo_levels.max(1) as usize; + if s.history.undo.len() > levels { + let extra = s.history.undo.len() - levels; + s.history.undo.drain(..extra); + } + } + Ok(v) + } + Err(e) => { + if changed { + s.project = before; + s.sanitize_state(); + s.bump(); + } + Err(e) + } + } +} + +fn id_of(v: &Value, key: &str) -> Result { + v.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("expected `{key}` in {v}"))) +} + +// ---------------------------------------------------------------- Edit ▸ History + +/// Edit ▸ History: `{steps: n}` undoes the last n steps (the entry n−1 of the list), `{redo: n}` +/// redoes n; without either, lists the steps (newest first). +fn history(s: &mut Session, p: &Value) -> Result { + if let Some(n) = p.get("steps").and_then(Value::as_u64) { + if n as usize > s.history.undo.len() { + return Err(bad("edit.history", format!("only {} undo steps", s.history.undo.len()))); + } + for _ in 0..n { + s.undo(); + } + } else if let Some(n) = p.get("redo").and_then(Value::as_u64) { + if n as usize > s.history.redo.len() { + return Err(bad("edit.history", format!("only {} redo steps", s.history.redo.len()))); + } + for _ in 0..n { + s.redo(); + } + } + let undo: Vec<&str> = s.history.undo.iter().rev().map(|(l, _)| l.as_str()).collect(); + let redo: Vec<&str> = s.history.redo.iter().rev().map(|(l, _)| l.as_str()).collect(); + Ok(json!({"undo": undo, "redo": redo})) +} + +fn has_history(s: &Session) -> std::result::Result<(), String> { + if s.history.undo.is_empty() && s.history.redo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } +} + +// ---------------------------------------------------------------- recent footage / presets + +fn import_recent(s: &mut Session, p: &Value) -> Result { + let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) { + (Some(x), _) => x.to_string(), + (None, Some(i)) => s.prefs.recent_footage.get(i as usize).cloned().ok_or_else(|| bad("file.importRecent", format!("no recent footage #{i}")))?, + _ => s.prefs.recent_footage.first().cloned().ok_or_else(|| bad("file.importRecent", "no recent footage"))?, + }; + super::file::import(s, &json!({"paths": [path]})) +} + +fn has_recent_footage(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_footage.is_empty() { Err("no recent footage".into()) } else { Ok(()) } +} + +fn clear_recent_footage(s: &mut Session, _: &Value) -> Result { + s.prefs.recent_footage.clear(); + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +fn apply_recent_preset(s: &mut Session, p: &Value) -> Result { + let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) { + (Some(x), _) => x.to_string(), + (None, Some(i)) => s.prefs.recent_presets.get(i as usize).cloned().ok_or_else(|| bad("anim.applyRecentPreset", format!("no recent preset #{i}")))?, + _ => s.prefs.recent_presets.first().cloned().ok_or_else(|| bad("anim.applyRecentPreset", "no recent animation presets"))?, + }; + let mut q = json!({"path": path}); + if let Some(l) = p.get("layers") { + q["layers"] = l.clone(); + } + s.execute("anim.applyPreset", q) +} + +fn has_recent_presets(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) } +} + +fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { + s.prefs.recent_presets.clear(); + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +// ---------------------------------------------------------------- File menu + +fn save_copy_xml(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; + let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?; + s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved an XML copy to {path}")); + Ok(json!({"path": path, "bytes": xml.len()})) +} + +/// File ▸ Replace Footage ▸ With Layered Comp: a layered (Photoshop) footage item becomes a +/// composition of its layers; every layer using the footage now uses the comp, and the footage +/// item goes. One undo step. +fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result { + let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied()); + let item = item.ok_or_else(|| bad("file.replaceWithLayeredComp", "select a layered footage item"))?; + let path = match s.project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.codec == "PSD" || f.path.to_ascii_lowercase().ends_with(".psd") => f.path.clone(), + _ => return Err(bad("file.replaceWithLayeredComp", "the item is not layered footage (a Photoshop file)")), + }; + let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + grouped(s, "Replace Footage", |s| { + let (comp, _, _) = super::file::import_psd_comp(s, &path, bytes, false)?; + let comp = ItemId(comp); + s.edit("Replace Footage", None, |proj, st| { + let ids: Vec = proj.items.keys().copied().collect(); + for cid in ids { + if cid == comp { + continue; + } + if let Some(c) = proj.comp_mut(cid) { + for l in &mut c.layers { + if matches!(&l.source, LayerSource::Footage { item: i } if *i == item) { + l.source = LayerSource::Comp { item: comp }; + } + } + } + } + proj.items.remove(&item); + st.project_selection = vec![comp]; + Ok(()) + })?; + Ok(json!({"comp": comp.0})) + }) +} + +fn has_footage(s: &Session) -> std::result::Result<(), String> { + has_project_selection(s)?; + let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_)))); + if ok { Ok(()) } else { Err("select a footage item".into()) } +} + +/// `File.execute()` from scripts (and agents): open a file with its default application. +/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on, +/// the frontend asks first (`confirmed: true` skips the question). +fn execute_file(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?; + if !s.prefs.scripting.allow_scripts_write_files { + return Err(bad( + "file.executeFile", + "scripts can't execute files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on", + )); + } + if !std::path::Path::new(path).exists() { + return Err(bad("file.executeFile", format!("no file {path}"))); + } + if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) { + s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) }); + return Ok(json!({"needsConfirmation": path})); + } + let url = format!("file://{}", path.replace(' ', "%20")); + s.events.push(Event::OpenUrl(url)); + Ok(json!({"opened": path})) +} + +// ---------------------------------------------------------------- Assign Shortcut to … + +/// The F10 / F11 / F12 (3D views) or Shift+F10 / F11 / F12 (workspaces) slots. +fn slot_p(p: &Value, cmd: &str, shift: bool) -> Result { + let raw = str_p(p, "slot").ok_or_else(|| bad(cmd, "missing `slot` (F10, F11 or F12)"))?; + let k = raw.trim().trim_start_matches("Shift+").to_ascii_uppercase(); + if !matches!(k.as_str(), "F10" | "F11" | "F12") { + return Err(bad(cmd, "slot: F10, F11 or F12")); + } + Ok(if shift { format!("Shift+{k}") } else { k }) +} + +/// Bind `keys` to the bindable `key`, taking it from whatever had it. +fn reassign(s: &mut Session, key: &str, slot: &str, cmd: &str) -> Result { + let t = s.shortcuts(); + let b = t.find(key).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut")))?; + let holders: Vec<(crate::shortcuts::Bindable, Vec)> = t + .bindables + .iter() + .filter(|x| x.key != key && t.keys.get(&x.key).is_some_and(|k| k.iter().any(|k| k == slot))) + .map(|x| (x.clone(), t.keys.get(&x.key).cloned().unwrap_or_default())) + .collect(); + let mine = t.keys.get(key).cloned().unwrap_or_default(); + let replaced: Vec = holders.iter().map(|(h, _)| h.label.clone()).collect(); + for (h, keys) in &holders { + let rest: Vec = keys.iter().filter(|k| *k != slot).cloned().collect(); + s.keymaps.set(h, &rest).map_err(|e| bad(cmd, e))?; + } + let mut keys = vec![slot.to_string()]; + keys.extend(mine.into_iter().filter(|k| k != slot)); + let created = s.keymaps.set(&b, &keys).map_err(|e| bad(cmd, e))?; + s.shortcuts_changed(); + if let Some(n) = &created { + s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\"")); + } + Ok(json!({"command": key, "keys": keys, "replaced": replaced, "preset": s.keymaps.active})) +} + +/// The command id of a 3D view. +pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static str { + use effectcraft_render::three_d::View3D::*; + match v { + ActiveCamera => "view.3d.activeCamera", + Front => "view.3d.front", + Left => "view.3d.left", + Top => "view.3d.top", + Back => "view.3d.back", + Right => "view.3d.right", + Bottom => "view.3d.bottom", + Custom1 => "view.3d.custom1", + Custom2 => "view.3d.custom2", + Custom3 => "view.3d.custom3", + } +} + +/// View ▸ Assign Shortcut to "": F10, F11 or F12 switches to the view. +fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { + let slot = slot_p(p, "view.assign3dShortcut", false)?; + let cid = comp_id(s, p)?; + let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + reassign(s, view_command(cur), &slot, "view.assign3dShortcut") +} + +/// Window ▸ Assign Shortcut to "" Workspace: Shift+F10, Shift+F11 or Shift+F12. +/// The frontend fills in the current workspace. +fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { + let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?; + let Some(ws) = str_p(p, "workspace") else { return super::frontend(s, "window.assignWorkspaceShortcut", p) }; + let key = crate::shortcuts::binding_key("window.workspace", &json!({"name": ws})); + reassign(s, &key, &slot, "window.assignWorkspaceShortcut") +} + +// ---------------------------------------------------------------- Help + +fn log_file(s: &Session) -> std::path::PathBuf { + crate::logging::log_path(s.config.as_ref().and_then(|c| c.dir()).as_deref()) +} + +fn enable_logging(s: &mut Session, p: &Value) -> Result { + let on = b_p(p, "on").unwrap_or(!crate::logging::is_enabled()); + let file = log_file(s); + crate::logging::set_enabled(on, file.clone()).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", file.display())))?; + s.prefs_revision += 1; + s.toast(if on { format!("Logging to {}", file.display()) } else { "Logging off".into() }); + Ok(json!({"enabled": on, "path": file.to_string_lossy()})) +} + +fn reveal_log(s: &mut Session, _: &Value) -> Result { + let file = crate::logging::current_file().unwrap_or_else(|| log_file(s)); + let path = file.to_string_lossy().to_string(); + let url = super::layer_menu::folder_url(&path); + s.events.push(Event::OpenUrl(url.clone())); + Ok(json!({"path": path, "url": url})) +} + +/// Media formats File ▸ Import reads (with the desktop host's decoders). +pub const IMPORT_FORMATS: &[&str] = &[ + "MP4/MOV (H.264, HEVC, ProRes, AV1)", + "WebM/MKV (VP8, VP9, AV1)", + "PNG", + "JPEG", + "GIF", + "WebP", + "TIFF", + "BMP", + "OpenEXR", + "Photoshop (PSD)", + "SVG", + "WAV", + "AIFF", + "MP3", + "FLAC", + "AAC", + "Opus", + "glTF/GLB", + "OBJ", + "Lottie JSON", +]; + +/// Help ▸ System Compatibility Report: OS, CPU, memory, GPU adapter, import/export formats. +pub fn system_report(s: &Session) -> Value { + let mem = crate::sysinfo::memory(); + let gb = |b: u64| format!("{:.1} GB", b as f64 / (1u64 << 30) as f64); + let export: Vec<&str> = s.exporter.as_ref().map(|e| e.formats().into_iter().map(|f| f.label()).collect()).unwrap_or_default(); + let gpu = s.accel.as_ref().map(|a| a.name()); + let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory)); + let mut issues = vec![]; + if gpu.is_none() { + issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string()); + } + if mem.is_some_and(|m| m.total < 8 << 30) { + issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string()); + } + if s.exporter.is_none() { + issues.push("Export is not available in this build.".to_string()); + } + json!({ + "app": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")), + "os": crate::sysinfo::os_version(), + "arch": std::env::consts::ARCH, + "cpu": crate::sysinfo::cpu_name(), + "cores": crate::sysinfo::cpu_cores(), + "memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})), + "gpu": gpu, + "caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)}, + "import": IMPORT_FORMATS, + "export": export, + "logging": crate::logging::is_enabled(), + "recentWarnings": crate::logging::recent().into_iter().rev().take(20).collect::>(), + "issues": issues, + }) +} + +/// The report as text (the dialog and `--` CLI output). +pub fn report_text(r: &Value) -> String { + let st = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string(); + let list = |k: &str| r.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect::>().join(", ")).unwrap_or_default(); + let mut t = format!( + "{}\nOperating system: {} ({})\nCPU: {} ({} logical cores)\n", + st("app"), + st("os"), + st("arch"), + st("cpu"), + r.get("cores").and_then(Value::as_u64).unwrap_or(1) + ); + match r.get("memory").filter(|m| !m.is_null()) { + Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))), + None => t.push_str("Memory: unknown\n"), + } + t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)"))); + let c = &r["caches"]; + t.push_str(&format!( + "Cache budgets: layer {}, footage {}, preview {}\n", + c["layer"].as_str().unwrap_or("?"), + c["footage"].as_str().unwrap_or("?"), + c["preview"].as_str().unwrap_or("?") + )); + t.push_str(&format!("Import: {}\nExport: {}\n", list("import"), list("export"))); + let issues = list("issues"); + t.push_str(&if issues.is_empty() { "No compatibility issues found.".to_string() } else { format!("Issues: {issues}") }); + t +} + +fn report(s: &mut Session, p: &Value) -> Result { + let r = system_report(s); + if !b_p(p, "quiet").unwrap_or(false) { + s.events.push(Event::Frontend { command: "app.showReport".into(), params: json!({"title": "System Compatibility Report", "text": report_text(&r)}) }); + } + Ok(r) +} + +// ---------------------------------------------------------------- Composition ▸ VR + +/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the +/// given world directions: searched over quarter turns, which is all the cube faces need. +fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { + let mut best = ([0.0; 3], f64::MAX); + for a in 0..4 { + for b in 0..4 { + for c in 0..4 { + let o = vec3(a as f64 * 90.0, b as f64 * 90.0, c as f64 * 90.0); + let m = Mat4::orientation(o); + let err = (m.apply_vec(vec3(1.0, 0.0, 0.0)) - x).length() + + (m.apply_vec(vec3(0.0, 1.0, 0.0)) - y).length() + + (m.apply_vec(vec3(0.0, 0.0, 1.0)) - z).length(); + if err < best.1 { + best = ([o.x, o.y, o.z], err); + } + } + } + } + best.0 +} + +/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up +/// in the VR convention (X right, Y up, Z front). +const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ + ("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), + ("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + ("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), + ("Bottom", [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + ("Front", [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + ("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), +]; +/// (column, row) of each face in the 3:2 cube map. +const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; + +/// VR world direction → After Effects world (Y down). +fn ae(v: [f64; 3]) -> Vec3 { + vec3(v[0], -v[1], v[2]) +} + +/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every +/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a +/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp +/// (VR Converter, cube map → equirectangular). One undo step. +fn create_vr_environment(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let size = p.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(64, 8192); + let center = match p.get("position").and_then(Value::as_array) { + Some(a) => [0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)), + None => [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0], + }; + let fps = src.frame_rate.as_f64(); + let dur = src.duration.seconds(); + let r = grouped(s, "Create VR Environment", |s| { + let mut faces = vec![]; + for (fname, fw, r, u) in FACES { + let fc = id_of( + &s.execute( + "comp.new", + json!({"name": format!("{name} VR {fname}"), "width": size, "height": size, "frameRate": fps, "duration": dur, "open": false}), + )?, + "comp", + )?; + let o = orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw)); + let cam = + id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?; + s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?; + s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?; + // The scene's layers (not its cameras), in the scene's coordinates, under the face + // camera. + let layers: Vec = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect(); + s.edit("Create VR Environment", None, |proj, _| { + let mut next = proj.next_id; + let mut map = std::collections::BTreeMap::new(); + let mut copies = vec![]; + for l in &layers { + let mut c = l.clone(); + super::edit::reid(&mut c, &mut next); + map.insert(l.id, c.id); + copies.push(c); + } + for c in &mut copies { + c.parent = c.parent.and_then(|p| map.get(&p).copied()); + if let Some(m) = &mut c.track_matte { + m.layer = map.get(&m.layer).copied().unwrap_or(m.layer); + } + } + proj.next_id = next; + let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?; + comp.layers.extend(copies); + Ok(()) + })?; + faces.push(fc); + } + let cube = id_of( + &s.execute( + "comp.new", + json!({"name": format!("{name} VR Cube Map"), "width": size * 3, "height": size * 2, "frameRate": fps, "duration": dur, "open": false}), + )?, + "comp", + )?; + for (i, fc) in faces.iter().enumerate() { + let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": fc, "time": 0.0}))?, "layer")?; + let (c, rr) = CELLS_3X2[i]; + let pos = [(c as f64 + 0.5) * size as f64, (rr as f64 + 0.5) * size as f64]; + s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/position", "value": pos}))?; + } + let out = id_of( + &s.execute( + "comp.new", + json!({"name": format!("{name} VR Output"), "width": size * 2, "height": size, "frameRate": fps, "duration": dur, "open": false}), + )?, + "comp", + )?; + let l = id_of(&s.execute("layer.addItem", json!({"comp": out, "item": cube, "time": 0.0}))?, "layer")?; + s.execute("prop.set", json!({"comp": out, "layer": l, "path": "transform/scale", "value": [200.0 / 3.0, 50.0, 100.0]}))?; + s.execute("effect.apply", json!({"comp": out, "layers": [l], "effect": "ec.vr.converter"}))?; + s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/sourceProjection", "value": 2}))?; + s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/targetProjection", "value": 0}))?; + Ok(json!({"faces": faces, "cubeMap": cube, "output": out})) + })?; + s.open_comp(ItemId(r["output"].as_u64().unwrap_or(0))); + Ok(r) +} + +/// Composition ▸ VR ▸ Extract Cubemap: a 3:2 cube-map comp of an equirectangular comp (VR +/// Converter, equirectangular → cube map), for editing the faces. One undo step. +fn extract_cubemap(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let face = p.get("faceSize").and_then(Value::as_u64).unwrap_or((src.width / 4).max(16) as u64).clamp(16, 8192); + let (w, h) = (face * 3, face * 2); + let r = grouped(s, "Extract Cubemap", |s| { + let cube = id_of( + &s.execute( + "comp.new", + json!({"name": format!("{name} Cubemap"), "width": w, "height": h, "frameRate": src.frame_rate.as_f64(), "duration": src.duration.seconds(), "open": false}), + )?, + "comp", + )?; + let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": cid.0, "time": 0.0}))?, "layer")?; + let scale = [w as f64 / src.width.max(1) as f64 * 100.0, h as f64 / src.height.max(1) as f64 * 100.0, 100.0]; + s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/scale", "value": scale}))?; + s.execute("effect.apply", json!({"comp": cube, "layers": [l], "effect": "ec.vr.converter"}))?; + s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/sourceProjection", "value": 0}))?; + s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/targetProjection", "value": 2}))?; + Ok(json!({"cubeMap": cube, "layer": l, "faceSize": face})) + })?; + s.open_comp(ItemId(r["cubeMap"].as_u64().unwrap_or(0))); + Ok(r) +} + +pub fn specs() -> Vec { + vec![ + cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), + cmd!("file.importRecent", "Import Recent Footage", [], None, "{index? | path?}", has_recent_footage, import_recent), + cmd!("file.clearRecentFootage", "Clear Recent Footage", ["File", "Import Recent Footage"], None, "{}", has_recent_footage, clear_recent_footage), + cmd!("anim.applyRecentPreset", "Recent Animation Presets", [], None, "{index? | path?, layers?}", has_recent_presets, apply_recent_preset), + cmd!( + "anim.clearRecentPresets", + "Clear Recent Presets", + ["Animation", "Recent Animation Presets"], + None, + "{}", + |s| if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) }, + clear_recent_presets + ), + cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml), + cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp), + cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file), + cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut), + cmd!( + "window.assignWorkspaceShortcut", + "Assign Shortcut to Workspace", + [], + None, + "{slot: Shift+F10|Shift+F11|Shift+F12, workspace?}", + always, + assign_workspace_shortcut + ), + cmd!("help.enableLogging", "Enable Logging", ["Help"], None, "{on?}", always, enable_logging), + cmd!("help.revealLogFile", "Reveal Logging File", ["Help"], None, "{}", always, reveal_log), + cmd!("help.systemReport", "System Compatibility Report...", ["Help"], None, "{quiet?}", always, report), + cmd!( + "comp.vr.createEnvironment", + "Create VR Environment...", + ["Composition", "VR"], + None, + "{comp?, size?: face pixels (1024), position?: [x,y,z]}", + has_comp, + create_vr_environment + ), + cmd!("comp.vr.extractCubemap", "Extract Cubemap...", ["Composition", "VR"], None, "{comp?, faceSize?}", has_comp, extract_cubemap), + query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 03cd80870..44f494668 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -384,6 +384,8 @@ fn paste(s: &mut Session, p: &Value) -> Result { fn split(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); + // Settings ▸ General ▸ Create Split Layers Above Original Layer. + let above = s.prefs.general.create_split_layers_above; let new = s.edit("Split Layer", None, |proj, st| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -398,7 +400,7 @@ fn split(s: &mut Session, p: &Value) -> Result { b.in_point = t; comp.layers[i].out_point = t; created.push(b.id); - comp.layers.insert(i, b); + comp.layers.insert(if above { i } else { i + 1 }, b); } proj.next_id = next; st.selected_layers = created.clone(); @@ -408,6 +410,11 @@ fn split(s: &mut Session, p: &Value) -> Result { } fn label(s: &mut Session, p: &Value) -> Result { + // Keyframe colour labels: selected keyframes take the label (unless layers are named). + let explicit_layers = p.get("layers").is_some() || p.get("layer").is_some(); + if p.get("keys").is_some() || (!s.state.selected_keys.is_empty() && !explicit_layers && str_p(p, "target") != Some("layers")) { + return super::keys_more::label_keys(s, p); + } let name = str_p(p, "label").unwrap_or("Red"); let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?; let (cid, ids) = layers_p(s, p)?; @@ -624,7 +631,7 @@ pub fn specs() -> Vec { cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all), - cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label), + cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label), cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group), cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge), cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches), diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 6999ac9d3..abe3596cc 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -40,6 +40,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; + // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. + let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; let proj = Project::from_json(&text)?; s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); @@ -48,6 +50,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { fn save_to(s: &mut Session, path: &str) -> Result { let json = s.project.to_json(); + // A project opened from an XML copy saves as XML again. + let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json }; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.saved_revision = s.revision; @@ -78,14 +82,20 @@ fn revert(s: &mut Session, _: &Value) -> Result { open(s, &json!({"path": path})) } -fn import(s: &mut Session, p: &Value) -> Result { +pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let paths: Vec = match p.get("paths").or(p.get("path")) { Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), Some(Value::String(x)) => vec![x.clone()], _ => return Err(bad("file.import", "missing `paths`")), }; // Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes). - let import_as = str_p(p, "importAs").unwrap_or("footage"); + // Files dragged in follow Settings ▸ Import ▸ Default Drag Import As. + let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false); + let import_as = str_p(p, "importAs").unwrap_or(if drag { s.prefs.drag_import_as() } else { "footage" }); + for path in &paths { + s.prefs.push_recent_footage(path); + } + s.save_prefs(); let retain = match import_as { "footage" => None, "composition" | "comp" => Some(false), @@ -127,9 +137,25 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut probed = vec![]; let psd_layer = p.get("layer").cloned(); let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps); + let mut ask_alpha = vec![]; + let mut warnings = vec![]; for path in &paths { match importer.probe(path) { Ok(mut f) => { + // Settings ▸ Import ▸ Report Missing Frames. + if f.kind == FootageKind::Sequence + && s.prefs.import.report_missing_frames + && let Some(gaps) = missing_frames(&f.sequence) + { + warnings.push(format!("{path}: {gaps}")); + } + // Settings ▸ Import ▸ Interpret Unlabeled Alpha As. + if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) { + match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) { + Some(a) => f.alpha = a, + None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)), + } + } // Settings ▸ Import ▸ Sequence Footage frames per second. if f.kind == FootageKind::Sequence { let r = seq_rate; @@ -154,6 +180,7 @@ fn import(s: &mut Session, p: &Value) -> Result { Err(e) => errors.push(format!("{path}: {e}")), } } + let mut asked: Vec = vec![]; s.edit("Import", None, |proj, st| { for (path, f) in probed { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); @@ -166,8 +193,12 @@ fn import(s: &mut Session, p: &Value) -> Result { FootageKind::Audio => Label::SeaFoam, FootageKind::Video | FootageKind::Model => Label::Aqua, }; + let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX)); let id = proj.add_item(&name, label, None, ItemKind::Footage(f)); ids.push(id.0); + if ask { + asked.push(id.0); + } st.project_selection = vec![id]; } Ok(()) @@ -175,7 +206,44 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(c) = out_comps.first() { s.open_comp(effectcraft_project::ItemId(*c)); } - Ok(json!({"items": ids, "comps": out_comps, "errors": errors})) + for w in &warnings { + s.events.push(crate::Event::Toast { message: w.clone(), error: true }); + } + // Ask User: the frontend opens Interpret Footage for footage with unlabeled alpha. + if let Some(first) = asked.first() { + s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) }); + } + Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked})) +} + +/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by +/// definition, OpenEXR premultiplied); TGA, TIFF and movies with alpha don't. +fn alpha_is_labeled(path: &str, codec: &str) -> bool { + let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD") +} + +/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the +/// end of each file name. `None` when the numbering is continuous (or there is none). +pub(crate) fn missing_frames(files: &[String]) -> Option { + let num = |f: &String| -> Option { + let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string(); + let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::>().into_iter().rev().collect(); + digits.parse().ok() + }; + let mut ns: Vec = files.iter().filter_map(num).collect(); + ns.sort_unstable(); + ns.dedup(); + let mut gaps = vec![]; + let mut total = 0; + for w in ns.windows(2) { + if w[1] - w[0] > 1 { + let (a, b) = (w[0] + 1, w[1] - 1); + total += b - a + 1; + gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") }); + } + } + (!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", "))) } /// A Photoshop layer by index or name (pixel layers only). @@ -189,7 +257,7 @@ fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, Strin } /// Import a Photoshop document as a composition (one undo step). Returns (comp, items, warnings). -fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Result<(u64, Vec, Vec)> { +pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Result<(u64, Vec, Vec)> { let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?; let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Photoshop".into()); let rate = effectcraft_time::FrameRate::FPS_29_97; @@ -313,7 +381,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/keys_more.rs b/crates/engine/src/commands/keys_more.rs new file mode 100644 index 000000000..0051d945e --- /dev/null +++ b/crates/engine/src/commands/keys_more.rs @@ -0,0 +1,298 @@ +//! Keyframe features of M14.5: Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes and +//! RPF Camera Import, keyframe colour labels (Edit ▸ Label on selected keyframes) and Edit ▸ +//! Select Keyframe Label Group. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::build::Ids; +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::app_more::grouped; +use super::{CommandSpec, bad, comp_id, has_comp, has_keys, layer_mut, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +// ---------------------------------------------------------------- keyframe labels + +/// The label index (0 = None, 1–16) of a label name (built-in or renamed in Settings ▸ Labels). +fn label_index(s: &Session, name: &str) -> Option { + let l = if name.eq_ignore_ascii_case("none") { Label::None } else { s.prefs.label_from_name(name)? }; + Label::ALL.iter().position(|x| *x == l).map(|i| i as u8) +} + +fn keys_p(s: &Session, p: &Value) -> Vec { + match p.get("keys") { + Some(Value::Array(a)) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), + _ => s.state.selected_keys.clone(), + } +} + +/// Edit ▸ Label on keyframes: colour the selected (or given) keyframes. +pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "label").unwrap_or("Red"); + let idx = label_index(s, name).ok_or_else(|| bad("keys.label", format!("unknown label `{name}`")))?; + let keys = keys_p(s, p); + if keys.is_empty() { + return Err(bad("keys.label", "select keyframes first")); + } + let cid = comp_id(s, p)?; + let n = s.edit("Keyframe Label", None, |proj, _| { + let mut n = 0; + for k in &keys { + let Ok(l) = layer_mut(proj, cid, k.layer) else { continue }; + let Some(pr) = l.props.find_mut(k.prop) else { continue }; + for key in pr.keys.iter_mut().filter(|x| x.time == k.time) { + key.label = idx; + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"keys": n, "label": idx})) +} + +/// Edit ▸ Select Keyframe Label Group: select every keyframe with the label of a selected +/// keyframe. `scope`: `selected` (selected layers), `all` (all layers), `visibleSelected` / +/// `visibleAll` (only properties the frontend shows, given as `props`: [uid]; agents without a +/// timeline may omit it, then every property counts). +fn select_label_group(s: &mut Session, p: &Value) -> Result { + let scope = str_p(p, "scope").unwrap_or("selected"); + if !matches!(scope, "selected" | "all" | "visibleSelected" | "visibleAll") { + return Err(bad("keys.selectLabelGroup", "scope: selected|all|visibleSelected|visibleAll")); + } + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let labels: Vec = s + .state + .selected_keys + .iter() + .filter_map(|k| comp.layer(k.layer).and_then(|l| l.props.find(k.prop)).and_then(|pr| pr.keys.iter().find(|x| x.time == k.time)).map(|x| x.label)) + .collect(); + if labels.is_empty() { + return Err(bad("keys.selectLabelGroup", "select a keyframe first")); + } + let visible: Option> = p.get("props").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); + let layers: Vec = + if scope == "all" || scope == "visibleAll" { comp.layers.iter().map(|l| l.id).collect() } else { s.state.selected_layers.clone() }; + let mut sel = vec![]; + for l in comp.layers.iter().filter(|l| layers.contains(&l.id)) { + l.props.walk("", &mut |_, pr| { + if scope.starts_with("visible") && visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) { + return; + } + for k in pr.keys.iter().filter(|k| labels.contains(&k.label)) { + sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time }); + } + }); + } + s.state.selected_keys = sel; + Ok(json!({"keys": s.state.selected_keys.len()})) +} + +// ---------------------------------------------------------------- Convert Audio to Keyframes + +/// Per-frame amplitude of interleaved stereo: (left, right, both), mean absolute sample × 100. +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) { + l += c[0].abs() as f64; + r += c[1].abs() as f64; + } + let (l, r) = (l / n * 100.0, r / n * 100.0); + (l, r, (l + r) / 2.0) +} + +/// Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes: an "Audio Amplitude" null with +/// Left Channel, Right Channel and Both Channels sliders keyed on every frame of the work area +/// with the amplitude of the comp's audible layers. One undo step. +fn audio_to_keys(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let fr = comp.frame_rate; + let (a, b) = comp.work_area; + let (f0, f1) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(fr.snap_nearest(b)).max(fr.frame_at(fr.snap_nearest(a)) + 1)); + const RATE: u32 = 48_000; + let per = ((RATE as f64 / fr.as_f64()).round() as usize).max(1); + let mut rows = vec![]; + for f in f0..f1 { + let t = fr.tick_of(f); + let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE); + rows.push((t, amplitudes(&buf))); + } + let spec = effectcraft_effects::lookup("ec.control.slider").ok_or_else(|| EngineError::Other("Slider Control is missing".into()))?; + let peak = rows.iter().map(|(_, (_, _, both))| *both).fold(0.0, f64::max); + let r = grouped(s, "Convert Audio to Keyframes", |s| { + let nl = s.execute("layer.newNull", json!({"comp": cid.0, "name": "Audio Amplitude"}))?["layer"].as_u64().unwrap_or(0); + let lid = LayerId(nl); + s.edit("Convert Audio to Keyframes", None, |proj, st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let start = l.start_time; + let fx = l.props.sub_mut("effects").ok_or_else(|| bad("keys.audioToKeyframes", "the null has no effects group"))?; + for (i, name) in ["Left Channel", "Right Channel", "Both Channels"].into_iter().enumerate() { + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("slider") { + pr.keys = rows + .iter() + .map(|(t, amp)| { + let v = [amp.0, amp.1, amp.2][i]; + Keyframe::new(*t - start, KV::Scalar((v * 100.0).round() / 100.0)) + }) + .collect(); + } + fx.children.push(g.into()); + } + proj.next_id = next; + st.selected_layers = vec![lid]; + Ok(()) + })?; + Ok(json!({"layer": nl, "frames": rows.len(), "peak": peak})) + })?; + Ok(r) +} + +// ---------------------------------------------------------------- RPF Camera Import + +/// One camera sample: time, position, orientation (degrees, X/Y/Z) and zoom (px). +#[derive(Clone, Debug, PartialEq)] +pub struct CamSample { + pub time: Tick, + pub position: [f64; 3], + pub orientation: [f64; 3], + pub zoom: Option, +} + +fn zoom_of(fov: Option, zoom: Option, comp_w: f64) -> Option { + zoom.or_else(|| fov.map(|a| comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan())) +} + +/// Parse camera data: JSON `{frameRate?, frames: [{frame | time, position, orientation | +/// rotation, zoom? | fov?}]}` or CSV with a header naming `frame`/`time`, `px py pz` +/// (or `x y z`), `rx ry rz` and `zoom`/`fov` columns. +pub fn parse_camera(text: &str, rate: effectcraft_time::FrameRate, comp_w: f64) -> std::result::Result, String> { + let t = text.trim_start(); + if t.starts_with('{') || t.starts_with('[') { + let v: Value = serde_json::from_str(t).map_err(|e| format!("camera JSON: {e}"))?; + let rate = v.get("frameRate").and_then(Value::as_f64).map(effectcraft_time::FrameRate::from_f64).unwrap_or(rate); + let frames = v.get("frames").or(Some(&v)).and_then(Value::as_array).ok_or("camera JSON: expected `frames`")?; + let v3 = |x: Option<&Value>| -> Option<[f64; 3]> { + let a = x?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)]) + }; + return frames + .iter() + .enumerate() + .map(|(i, f)| { + let time = match (f.get("time").and_then(Value::as_f64), f.get("frame").and_then(Value::as_i64)) { + (Some(s), _) => Tick::from_seconds_f64(s), + (None, Some(n)) => rate.tick_of(n), + _ => rate.tick_of(i as i64), + }; + Ok(CamSample { + time, + position: v3(f.get("position")).ok_or(format!("camera JSON: frame {i} needs `position` [x,y,z]"))?, + orientation: v3(f.get("orientation").or(f.get("rotation"))).unwrap_or([0.0; 3]), + zoom: zoom_of(f.get("fov").and_then(Value::as_f64), f.get("zoom").and_then(Value::as_f64), comp_w), + }) + }) + .collect(); + } + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')); + let head: Vec = lines.next().ok_or("camera CSV: empty")?.split([',', ';', '\t']).map(|h| h.trim().to_ascii_lowercase()).collect(); + let col = |names: &[&str]| head.iter().position(|h| names.contains(&h.as_str())); + let (cf, ct) = (col(&["frame", "f"]), col(&["time", "seconds", "t"])); + let pos = [col(&["px", "x", "posx", "position x"]), col(&["py", "y", "posy", "position y"]), col(&["pz", "z", "posz", "position z"])]; + let rot = [col(&["rx", "rotx", "orientation x", "tilt"]), col(&["ry", "roty", "orientation y", "pan"]), col(&["rz", "rotz", "orientation z", "roll"])]; + let (cz, cfov) = (col(&["zoom"]), col(&["fov", "angle", "angleofview"])); + if pos.iter().any(Option::is_none) { + return Err("camera CSV: needs px, py, pz (or x, y, z) columns".into()); + } + lines + .enumerate() + .map(|(i, l)| { + let cells: Vec = l.split([',', ';', '\t']).map(|c| c.trim().parse().unwrap_or(f64::NAN)).collect(); + let get = |c: Option| c.and_then(|c| cells.get(c).copied()).filter(|v| v.is_finite()); + let time = match (get(ct), get(cf)) { + (Some(s), _) => Tick::from_seconds_f64(s), + (None, Some(n)) => rate.tick_of(n as i64), + _ => rate.tick_of(i as i64), + }; + Ok(CamSample { + time, + position: pos.map(|c| get(c).unwrap_or(0.0)), + orientation: rot.map(|c| get(c).unwrap_or(0.0)), + zoom: zoom_of(get(cfov), get(cz), comp_w), + }) + }) + .collect() +} + +/// Animation ▸ Keyframe Assistant ▸ RPF Camera Import: a camera layer keyed from camera data. +/// RPF/RLA files carry their camera in an undocumented block, so EffectCraft reads the same data +/// from JSON or CSV exports (see `docs/preferences.md` ▸ RPF Camera Import). One undo step. +fn rpf_camera_import(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("keys.rpfCameraImport", "missing `path` (.json or .csv camera data)"))?; + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".rpf") || lower.ends_with(".rla") { + return Err(bad( + "keys.rpfCameraImport", + "the camera block of RPF/RLA files is not publicly documented: export the camera from your 3D application as JSON or CSV (frame, px, py, pz, rx, ry, rz, zoom|fov)", + )); + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let samples = parse_camera(&String::from_utf8_lossy(&bytes), comp.frame_rate, comp.width as f64).map_err(|e| bad("keys.rpfCameraImport", e))?; + if samples.is_empty() { + return Err(bad("keys.rpfCameraImport", "no camera samples")); + } + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "RPF Camera".into()); + grouped(s, "RPF Camera Import", |s| { + let cam = s.execute("layer.newCamera", json!({"comp": cid.0, "name": name, "type": "oneNode"}))?["layer"].as_u64().unwrap_or(0); + let lid = LayerId(cam); + s.edit("RPF Camera Import", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let start = l.start_time; + let keyed = |vals: Vec<(Tick, KV)>| -> Vec { vals.into_iter().map(|(t, v)| Keyframe::new(t - start, v)).collect() }; + if let Some(pr) = l.props.prop_mut("transform/position") { + pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.position))).collect()); + } + if let Some(pr) = l.props.prop_mut("transform/orientation") { + pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.orientation))).collect()); + } + if samples.iter().any(|c| c.zoom.is_some()) + && let Some(pr) = l.props.prop_mut("cameraOptions/zoom") + { + pr.keys = keyed(samples.iter().filter_map(|c| Some((c.time, KV::Scalar(c.zoom?)))).collect()); + } + Ok(()) + })?; + Ok(json!({"layer": cam, "keyframes": samples.len()})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], None, "{comp?}", has_comp, audio_to_keys), + cmd!( + "keys.rpfCameraImport", + "RPF Camera Import", + ["Animation", "Keyframe Assistant"], + None, + "{path: .json|.csv camera data, comp?}", + has_comp, + rpf_camera_import + ), + cmd!( + "keys.selectLabelGroup", + "Select Keyframe Label Group", + [], + None, + "{scope: selected|all|visibleSelected|visibleAll, props?: [uid]}", + has_keys, + select_label_group + ), + cmd!("keys.label", "Keyframe Label", [], None, "{label, keys?: [{layer, prop, time}]}", has_keys, label_keys), + ] +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 7b44096ef..f34ed2f56 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -822,12 +822,13 @@ fn add_mask(s: &mut Session, p: &Value) -> Result { _ => ShapePath::rect([cx, cy], rw, rh), }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let cycle = s.prefs.appearance.cycle_mask_colors; let uid = s.edit("New Mask", None, |proj, _| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?; let n = masks.children.len(); - let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n)); let uid = g.uid; masks.children.push(g.into()); proj.next_id = next; @@ -909,6 +910,11 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setText")?; + // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). + if let Some(f) = str_p(p, "font") { + s.prefs.push_recent_font(f); + s.save_prefs(); + } let t = s.time(); let range = text_range_p(p); // Character attributes with only a caret (no selected text) set the insertion style. diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index b441c366a..263d0d58a 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -652,33 +652,38 @@ fn reveal_comp(s: &mut Session, p: &Value) -> Result { Ok(json!({"item": cid.0})) } -fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let t = s.time(); - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let mut errors = vec![]; - for l in &comp.layers { - let mut has = vec![]; - l.props.walk("", &mut |_, pr| { - if pr.has_expression() { - has.push(pr); - } - }); - for pr in has { - let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default(); - let err = match &s.expr { - Some(host) => { - let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t); - ctx.expr = Some(host.as_ref()); - host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err() +impl Session { + /// Expressions of comp `cid` that fail at its current time: `{layer, prop, name, expression, + /// error}` (Reveal Expression Errors, the viewer's expression error banner). + pub fn expression_errors(&self, cid: effectcraft_project::ItemId) -> Vec { + let t = self.time_of(cid); + let Some(comp) = self.project.comp(cid) else { return vec![] }; + let Some(host) = &self.expr else { return vec![] }; + let mut errors = vec![]; + for l in &comp.layers { + let mut has = vec![]; + l.props.walk("", &mut |_, pr| { + if pr.has_expression() { + has.push(pr); + } + }); + for pr in has { + let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default(); + let mut ctx = effectcraft_render::EvalCtx::new(&self.project, cid, comp, t); + ctx.expr = Some(host.as_ref()); + if let Err(e) = host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))) { + errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e})); } - None => None, - }; - if let Some(e) = err { - errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e})); } } + errors } +} + +fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.project.comp(cid).ok_or(EngineError::NoComp)?; + let errors = s.expression_errors(cid); let props: Vec = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect(); frontend(s, "timeline.revealProps", &json!({"props": props}))?; if errors.is_empty() { diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs index 1e070ab5f..3892bd06b 100644 --- a/crates/engine/src/commands/link.rs +++ b/crates/engine/src/commands/link.rs @@ -101,7 +101,7 @@ fn quote(s: &str) -> String { } /// The AE expression reference to property `uid` of `target` as seen from a property of `from`. -pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option { +pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option { let chain = target.props.node_chain(uid)?; let mut parts: Vec = vec![]; let mut pending: Option<&str> = None; @@ -125,6 +125,15 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> } let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) }; let _ = comp; + if !compact { + // Match names: language-independent, like After Effects with Compact English off. + let chain = target.props.node_chain(uid)?; + let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) }; + for n in chain { + out.push_str(&format!("({})", quote(n.match_id()))); + } + return Some(out); + } for (i, p) in parts.iter().enumerate() { if i > 0 && !p.starts_with('(') { out.push('.'); @@ -152,7 +161,7 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let from = comp.layer(lid).ok_or(EngineError::NoComp)?; let target = comp.layer(tl).ok_or(EngineError::NoComp)?; - let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); let text = if dt == 1 && dm > 1 { diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 39894b581..f34d673b1 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -54,9 +54,76 @@ pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&' /// Edit the mask path of (layer, mask) at the current time. fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { + edit_path_counted(s, p, label, |sp| f(sp).map(|r| (r, None))) +} + +/// A change of a path's vertex count, repeated on the other keyframes of an animated path when +/// Settings ▸ General ▸ Preserve Constant Vertex and Feather Point Count is on. +#[derive(Clone, Debug)] +pub(crate) enum CountOp { + /// A vertex was inserted at this index. + Insert(usize), + /// These vertices were removed. + Remove(Vec), +} + +/// Apply a vertex-count change to another keyframe's path: an inserted vertex splits the +/// matching segment in half (the shape doesn't change), removed vertices go. +pub(crate) fn apply_count_op(sp: &mut ShapePath, op: &CountOp) { + match op { + CountOp::Insert(at) => { + let n = sp.vertices.len(); + if n == 0 { + return; + } + if *at > 0 && split_segment(sp, at - 1, 0.5).is_some() { + return; + } + let at = (*at).min(n); + let v = sp.vertices[at.min(n - 1)]; + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + sp.vertices.insert(at, v); + sp.in_tangents.insert(at, [0.0; 2]); + sp.out_tangents.insert(at, [0.0; 2]); + } + CountOp::Remove(idx) => { + let mut idx = idx.clone(); + idx.sort_unstable(); + idx.dedup(); + for &i in idx.iter().rev() { + if i < sp.vertices.len() { + sp.vertices.remove(i); + if i < sp.in_tangents.len() { + sp.in_tangents.remove(i); + } + if i < sp.out_tangents.len() { + sp.out_tangents.remove(i); + } + } + } + if sp.vertices.len() < 3 { + sp.closed = false; + } + } + } +} + +/// Repeat `op` on every keyframe of `pr` other than the one at `lt`. +fn sync_keys(pr: &mut effectcraft_project::Property, lt: effectcraft_time::Tick, op: &CountOp) { + for k in pr.keys.iter_mut().filter(|k| k.time != lt) { + if let KV::Path(sp) = &mut k.value { + apply_count_op(sp, op); + } + } +} + +/// [`edit_path`] whose edit may change the vertex count (`f` says how). +fn edit_path_counted(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<(T, Option)>) -> Result<(Uid, T)> { let (cid, lid) = layer_p(s, p, label)?; let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?; let t = s.time(); + let preserve = s.prefs.general.preserve_constant_vertex_count; s.edit(label, merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); @@ -65,11 +132,14 @@ fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; - let r = f(&mut sp)?; + let (r, op) = f(&mut sp)?; if roto { super::paths::roto_smooth(&mut sp); } pr.set_value_at(lt, KV::Path(sp)); + if let Some(op) = op.filter(|_| preserve) { + sync_keys(pr, lt, &op); + } Ok((uid, r)) }) } @@ -88,12 +158,13 @@ fn new_mask(s: &mut Session, p: &Value) -> Result { outs.resize(n, [0.0; 2]); let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let cycle = s.prefs.appearance.cycle_mask_colors; let uid = s.edit("New Mask", None, |proj, st| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?; let k = masks.children.len(); - let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, crate::prefs::mask_color(cycle, k)); let uid = g.uid; masks.children.push(g.into()); proj.next_id = next; @@ -109,12 +180,12 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { let tout = pt(p.get("out")).unwrap_or([0.0; 2]); let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let (_, lid) = layer_p(s, p, "mask.addVertex")?; - let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| { + let (uid, i) = edit_path_counted(s, p, "mask.addVertex", |sp| { let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len()); sp.vertices.insert(i, point); sp.in_tangents.insert(i, tin); sp.out_tangents.insert(i, tout); - Ok(i) + Ok((i, Some(CountOp::Insert(i)))) })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; Ok(json!(i)) @@ -196,9 +267,16 @@ fn has_vertices(s: &Session) -> std::result::Result<(), String> { /// Apply `f` to the selected vertices' paths (each mask once). fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result { + edit_vertices_counted(s, p, label, false, f) +} + +/// [`edit_vertices`]; `removes`: `f` deletes the given vertices (repeated on the other keyframes +/// when Preserve Constant Vertex Count is on). +fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool, f: impl Fn(&mut ShapePath, &[usize])) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let groups = vertex_groups(s, p)?; let t = s.time(); + let preserve = removes && s.prefs.general.preserve_constant_vertex_count; s.edit(label, merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for ((l, m), idx) in &groups { @@ -213,6 +291,9 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP super::paths::roto_smooth(&mut sp); } pr.set_value_at(lt, KV::Path(sp)); + if preserve { + sync_keys(pr, lt, &CountOp::Remove(idx.clone())); + } } Ok(()) })?; @@ -232,7 +313,7 @@ fn move_vertices(s: &mut Session, p: &Value) -> Result { } fn delete_vertices(s: &mut Session, p: &Value) -> Result { - let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| { + let n = edit_vertices_counted(s, p, "Delete Mask Vertices", true, |sp, idx| { let mut idx = idx.to_vec(); idx.sort_unstable(); idx.dedup(); @@ -333,7 +414,10 @@ fn insert_vertex(s: &mut Session, p: &Value) -> Result { p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize; let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0); let (_, lid) = layer_p(s, p, "mask.insertVertex")?; - let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?; + let (uid, i) = edit_path_counted(s, p, "Add Vertex", |sp| { + let i = split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment"))?; + Ok((i, Some(CountOp::Insert(i)))) + })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; Ok(json!(i)) } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 2e2d2782c..06d9f14a3 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -4,6 +4,7 @@ mod anim; pub mod anim_tools; mod animation; +mod app_more; mod camera_cmds; mod comp; mod comp_more; @@ -15,6 +16,7 @@ mod file_more; mod frontend; mod help; mod key_transform; +mod keys_more; mod layer; mod layer_menu; mod layer_time; @@ -46,6 +48,13 @@ mod view; pub mod viewer_cmds; mod warp_cmds; #[cfg(test)] +pub(crate) use app_more::report_text as report_text_for_tests; +pub(crate) use app_more::view_command; +#[cfg(test)] +pub(crate) use file::missing_frames as missing_frames_for_tests; +#[cfg(test)] +pub(crate) use keys_more::amplitudes as amplitudes_for_tests; +#[cfg(test)] pub(crate) use mask::split_segment as split_segment_for_tests; use std::sync::OnceLock; @@ -144,6 +153,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(puppet::specs()); v.extend(liquify::specs()); v.extend(settings::specs()); + v.extend(app_more::specs()); + v.extend(keys_more::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 05dc37fad..d10fa232d 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -184,7 +184,7 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { enabled = Some(false); } // Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`). - let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into()); + let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into()); let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { match (&text, enabled) { (Some(t), _) if t.trim().is_empty() => pr.expr = None, diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index be6cebfb7..15b5a3a20 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -495,8 +495,15 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { let mut item = RenderQueueItem::new(0, cid); item.output = OutputModule::for_format(format); item.output.output = path.clone(); - let job = - crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path }; + let job = crate::ExportJob { + project: &s.project, + footage: s.footage.as_ref(), + expr: s.expr.as_deref(), + accel: s.accel.as_deref(), + item: &item, + path: &path, + nested_switches: s.prefs.general.switches_affect_nested_comps, + }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); Ok(serde_json::to_value(&r).unwrap_or_default()) diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 27cbc5d47..014360ffd 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -23,7 +23,6 @@ pub fn specs() -> Vec { stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"), stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"), stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"), - stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"), // Layer ▸ New (3D / content-aware fill). stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), // Mask options not in the mask model yet; pen / vertex editing (timeline milestone). @@ -47,8 +46,6 @@ pub fn specs() -> Vec { stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"), stub!("view.simulateOutput", "Simulate Output", [], "{profile}"), // Keyframes / text / tracking. - stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), - stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"), // Workspaces and panels that don't exist yet. stub!("window.unavailablePanel", "Panel", [], "{panel}"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6dbe2c4b..45f5c0f60 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,7 +14,9 @@ pub mod commands; pub mod config; pub mod demo; pub mod links; +pub mod logging; pub mod mask_track; +pub mod media_cache; pub mod menus; pub mod prefs; pub mod psd_import; @@ -22,10 +24,12 @@ pub mod render_queue; pub mod roto; mod session_settings; pub mod shortcuts; +pub mod sysinfo; pub mod tracking; pub mod vector; pub mod viewer; pub mod warp; +pub mod xml_project; use std::sync::Arc; @@ -309,6 +313,11 @@ pub struct Session { /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): /// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console. pub script: Option, + /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown + /// (headless sessions don't query it, see [`Session::memory_tick`]). + pub sys_memory: Option, + /// Switches Affect Nested Comps as last applied (a change empties the layer cache). + pub applied_nested_switches: Option, } /// A script to run (see [`Session::script`]). @@ -363,6 +372,8 @@ impl Default for Session { autosave: autosave::AutoSaveState::default(), snapshot: None, script: None, + sys_memory: None, + applied_nested_switches: None, } } } @@ -527,6 +538,47 @@ impl Session { None => t, }; self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + self.sync_related_times(c, t); + } + } + } + + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of + /// `comp` moves the current time of the comps nested in it (through their precomp layers' + /// timing) and of the comps it is nested in, so every related viewer shows the same moment. + pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { + let mut seen = std::collections::BTreeSet::from([comp]); + let mut todo = vec![(comp, t)]; + let mut found = vec![]; + while let Some((cid, ct)) = todo.pop() { + if cid != comp { + found.push((cid, ct)); + } + let Some(c) = self.project.comp(cid) else { continue }; + // Down: the comps this one nests. + for l in &c.layers { + if let effectcraft_project::LayerSource::Comp { item } = &l.source + && seen.insert(*item) + { + todo.push((*item, l.layer_time(ct))); + } + } + // Up: the comps nesting this one. + for iid in self.project.items.keys() { + if let Some(pc) = self.project.comp(*iid) + && let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid)) + && seen.insert(*iid) + { + todo.push((*iid, l.comp_time(ct))); + } + } + } + for (cid, ct) in found { + if let Some(c) = self.project.comp(cid) { + let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + self.state.times.insert(cid, ct); + } } } @@ -568,6 +620,7 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.layer_cache); @@ -649,6 +702,8 @@ mod tests_fidelity; #[cfg(test)] mod tests_lottie; #[cfg(test)] +mod tests_m145; +#[cfg(test)] mod tests_markers; #[cfg(test)] mod tests_mask_warp; @@ -686,6 +741,68 @@ pub fn text_presets() -> Vec<(String, String)> { pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } + +/// One row of the Character panel's font menu. +#[derive(Clone, Debug, PartialEq)] +pub struct FontRow { + /// The family to apply (empty for the separator after the recent fonts). + pub family: String, + /// What the menu shows: the English family name, or the font's own-language name when + /// Settings ▸ Type ▸ Show Font Names in English is off. + pub display: String, + pub recent: bool, +} + +/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for +/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). +pub fn font_preview(family: &str, size: f64) -> Vec> { + use kurbo::PathEl; + let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; + let mut out = vec![]; + let mut x = 0.0; + for ch in "Sample".chars() { + let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); + let mut cur: Vec<[f32; 2]> = vec![]; + let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; + kurbo::flatten(&path, 0.2, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur = vec![pt(p)]; + } + PathEl::LineTo(p) => cur.push(pt(p)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + x += adv; + } + out +} + +/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts +/// to Display), a separator, then every family. +pub fn font_menu(prefs: &prefs::Prefs) -> Vec { + let all = text_families(); + let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; + let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent }; + let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); + if !out.is_empty() { + out.push(FontRow { family: String::new(), display: "-".into(), recent: false }); + } + out.extend(all.iter().map(|f| row(f, false))); + out +} #[cfg(test)] mod tests_paint; #[cfg(test)] diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs new file mode 100644 index 000000000..23ef28f0e --- /dev/null +++ b/crates/engine/src/logging.rs @@ -0,0 +1,99 @@ +//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends +//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder), +//! and always keeps the last few hundred warnings and errors in memory for the System +//! Compatibility Report. +//! +//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging` +//! command) turns file logging on and off. + +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static STATE: Mutex = Mutex::new(State { file: None, recent: VecDeque::new() }); + +struct State { + file: Option, + recent: VecDeque, +} + +struct Logger; + +const RECENT: usize = 300; + +impl log::Log for Logger { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) + } + fn log(&self, r: &log::Record) { + if !self.enabled(r.metadata()) { + return; + } + let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args()); + let Ok(mut st) = STATE.lock() else { return }; + if r.level() <= log::Level::Warn { + st.recent.push_back(line.clone()); + if st.recent.len() > RECENT { + st.recent.pop_front(); + } + } + if ENABLED.load(Ordering::Relaxed) + && let Some(f) = &st.file + && let Ok(mut out) = std::fs::OpenOptions::new().create(true).append(true).open(f) + { + let _ = writeln!(out, "{line}"); + } + } + fn flush(&self) {} +} + +fn unix_secs() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Install the logger (once; later calls and other installed loggers are left alone). +pub fn install() { + static LOGGER: Logger = Logger; + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Info); + } +} + +/// The log file inside `config_dir` (the settings folder), or the temporary folder. +pub fn log_path(config_dir: Option<&Path>) -> PathBuf { + config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt") +} + +/// Turn file logging on (writing to `file`) or off. +pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> { + install(); + if on { + if let Some(d) = file.parent() { + std::fs::create_dir_all(d)?; + } + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?; + writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?; + } + if let Ok(mut st) = STATE.lock() { + st.file = Some(file); + } + ENABLED.store(on, Ordering::Relaxed); + Ok(()) +} + +pub fn is_enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + +/// The current log file (set once logging was enabled). +pub fn current_file() -> Option { + STATE.lock().ok().and_then(|s| s.file.clone()) +} + +/// Recent warnings and errors, oldest first. +pub fn recent() -> Vec { + STATE.lock().map(|s| s.recent.iter().cloned().collect()).unwrap_or_default() +} diff --git a/crates/engine/src/media_cache.rs b/crates/engine/src/media_cache.rs new file mode 100644 index 000000000..1a9b932ae --- /dev/null +++ b/crates/engine/src/media_cache.rs @@ -0,0 +1,65 @@ +//! The media cache (Settings ▸ Disk ▸ Database and Cache Folder): audio waveform summaries kept +//! on disk so a footage file is summarised once, not in every session. +//! +//! A summary is stored as `Peaks/_.ecpk` (raw little-endian `f32` quadruples +//! `[min L, max L, min R, max R]` per bin), named by a hash of the footage path, its size and +//! modification time, so an edited file is summarised again. + +use std::path::{Path, PathBuf}; + +/// The peak file of footage `path` at `bins` bins per second in the media cache `folder` +/// (`None` when the file can't be inspected). +pub fn peaks_path(folder: &Path, path: &str, bins: u32) -> Option { + use std::hash::{Hash, Hasher}; + let meta = std::fs::metadata(path).ok()?; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (path, meta.len(), meta.modified().ok()).hash(&mut h); + Some(folder.join("Peaks").join(format!("{:016x}_{bins}.ecpk", h.finish()))) +} + +/// Read a stored summary. +pub fn load_peaks(file: &Path) -> Option> { + let b = std::fs::read(file).ok()?; + if b.len() % 16 != 0 { + 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()) +} + +/// Store a summary (atomically: written beside, then renamed). +pub fn store_peaks(file: &Path, peaks: &[[f32; 4]]) -> std::io::Result<()> { + if let Some(d) = file.parent() { + std::fs::create_dir_all(d)?; + } + let mut b = Vec::with_capacity(peaks.len() * 16); + for p in peaks { + for v in p { + b.extend_from_slice(&v.to_le_bytes()); + } + } + crate::config::atomic_write(file, &b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peaks_round_trip_and_follow_the_file() { + let d = std::env::temp_dir().join(format!("ec-media-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let src = d.join("a.wav"); + std::fs::write(&src, b"one").unwrap(); + let p = peaks_path(&d, src.to_str().unwrap(), 200).unwrap(); + assert!(p.starts_with(d.join("Peaks"))); + let peaks = vec![[-0.5, 0.5, -0.25, 0.25], [0.0, 0.1, -0.1, 0.0]]; + store_peaks(&p, &peaks).unwrap(); + assert_eq!(load_peaks(&p).unwrap(), peaks); + // A changed file gets another name. + std::fs::write(&src, b"longer").unwrap(); + assert_ne!(peaks_path(&d, src.to_str().unwrap(), 200).unwrap(), p); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0191c6eaa..e1ef4e8f6 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -12,6 +12,8 @@ //! Next Blending Mode | layer.setBlendMode {"step":1} | Shift+= //! --- separator //! @effects the effect categories (Effect menu) +//! @dynamic:history entries computed from the session when the menu +//! opens ([`dynamic`]: recent files, undo history…) //! [mac] Quit EffectCraft | app.quit only on macOS ([!mac] = everywhere else) //! ``` //! @@ -34,11 +36,120 @@ use crate::commands::{self, CommandSpec}; pub enum MenuNode { Item(MenuEntry), Separator, - Submenu { label: String, children: Vec }, + Submenu { + label: String, + children: Vec, + }, + /// Entries computed when the menu opens (see [`dynamic`]). + Dynamic { + name: String, + }, +} + +/// Frontend state dynamic menus depend on. +#[derive(Clone, Copy, Debug, Default)] +pub struct DynCtx<'a> { + /// The current workspace (Window ▸ Assign Shortcut to "…" Workspace). + pub workspace: Option<&'a str>, +} + +fn file_name(p: &str) -> String { + std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string()) +} + +fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry { + MenuEntry { label, command: command.into(), params, shortcut: None } +} + +/// The bindable holding shortcut `keys` in the active preset (its label). +fn holder_of(s: &Session, keys: &str) -> Option { + let t = s.shortcuts(); + t.bindables.iter().find(|b| t.keys.get(&b.key).is_some_and(|k| k.iter().any(|k| k == keys))).map(|b| b.label.clone()) +} + +/// The entries of a `@dynamic:` node, and the disabled placeholder shown when there are +/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest +/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other +/// than the active one). +pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { + use serde_json::json; + let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec { + list.iter() + .enumerate() + .map(|(i, p)| { + let n = + if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) }; + dyn_entry(n, cmd, json!({"index": i})) + }) + .collect() + }; + match name { + "recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")), + "recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")), + "recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")), + "history" => ( + s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(), + Some("No History"), + ), + "view3dShortcuts" => ( + ["F10", "F11", "F12"] + .iter() + .map(|k| { + let label = match holder_of(s, k) { + Some(h) => format!("{k} (Replace “{h}”)"), + None => k.to_string(), + }; + dyn_entry(label, "view.assign3dShortcut", json!({"slot": k})) + }) + .collect(), + None, + ), + "workspaceShortcuts" => ( + ["Shift+F10", "Shift+F11", "Shift+F12"] + .iter() + .map(|k| { + let label = match holder_of(s, k) { + Some(h) => format!("{k} (Replace “{h}”)"), + None => k.to_string(), + }; + let mut p = json!({"slot": k}); + if let Some(w) = cx.workspace { + p["workspace"] = json!(w); + } + dyn_entry(label, "window.assignWorkspaceShortcut", p) + }) + .collect(), + None, + ), + "openViewers" => ( + s.state + .open_comps + .iter() + .filter(|c| Some(**c) != s.state.active_comp) + .filter_map(|c| s.project.item(*c).map(|i| dyn_entry(format!("Composition: {}", i.name), "comp.open", json!({"comp": c.0})))) + .collect(), + None, + ), + _ => (vec![], None), + } +} + +/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or +/// workspace, like After Effects). +pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String { + match label { + "Assign Shortcut to 3D View" => { + let v = s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); + let name = crate::commands::find(crate::commands::view_command(v)).map(|c| c.label).unwrap_or("Active Camera"); + format!("Assign Shortcut to “{name}”") + } + "Assign Shortcut to Workspace" => format!("Assign Shortcut to “{}” Workspace", cx.workspace.unwrap_or("Default")), + _ => label.to_string(), + } } /// A clickable menu entry. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct MenuEntry { pub label: String, pub command: String, @@ -80,7 +191,7 @@ pub fn entries() -> Vec<(Vec, &'static MenuEntry)> { for n in nodes { match n { MenuNode::Item(e) => out.push((path.clone(), e)), - MenuNode::Separator => {} + MenuNode::Separator | MenuNode::Dynamic { .. } => {} MenuNode::Submenu { label, children } => { path.push(label.clone()); rec(children, path, out); @@ -96,7 +207,23 @@ pub fn entries() -> Vec<(Vec, &'static MenuEntry)> { /// Display label of an entry: label names come from Settings ▸ Labels. pub fn entry_label(s: &Session, e: &MenuEntry) -> String { + let comp_name = || s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into()); + let layer_name = + || s.active_comp().and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.name.clone()).unwrap_or_else(|| "(none)".into()); match e.command.as_str() { + // Window menu: the panels' current viewer instances. + "window.panel" => match e.params.get("panel").and_then(Value::as_str) { + Some("composition") => format!("Composition: {}", comp_name()), + Some("timeline") => format!("Timeline: {}", comp_name()), + Some("flowchart") => format!("Flowchart: {}", comp_name()), + Some("layer") => format!("Layer: {}", layer_name()), + Some("effectControls") if e.label == "Effect Controls" => format!("Effect Controls: {}", layer_name()), + _ => e.label.clone(), + }, + "window.unavailablePanel" if e.params.get("panel").and_then(Value::as_str) == Some("footage") => { + let f = s.state.project_selection.iter().filter_map(|i| s.project.item(*i)).find(|i| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))); + format!("Footage: {}", f.map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into())) + } "edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) { Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l), _ => e.label.clone(), @@ -108,6 +235,9 @@ pub fn entry_label(s: &Session, e: &MenuEntry) -> String { /// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles /// (rulers, grid…) are answered by the frontend. pub fn checked(s: &Session, command: &str, params: &Value) -> Option { + if command == "help.enableLogging" { + return Some(crate::logging::is_enabled()); + } let comp = s.active_comp()?; let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l)); let pstr = |k: &str| params.get(k).and_then(Value::as_str); @@ -236,6 +366,10 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { out.extend(effect_categories()); continue; } + if let Some(name) = l.body.strip_prefix("@dynamic:") { + out.push(MenuNode::Dynamic { name: name.trim().to_string() }); + continue; + } let mut parts = l.body.split(" | "); let label = parts.next().unwrap_or_default().trim().to_string(); match parts.next() { @@ -321,6 +455,8 @@ File New Folder | project.newFolder Open Project... | file.open Open Recent + @dynamic:recentProjects + --- Clear Recent Projects | file.clearRecent --- Close | file.close @@ -329,6 +465,7 @@ File Save As Save As... | file.saveAs Save a Copy... | file.saveCopy + Save a Copy As XML... | file.saveCopyAsXml Increment and Save | file.incrementAndSave Revert | file.revert --- @@ -338,6 +475,10 @@ File Placeholder... | file.importPlaceholder Solid... | file.importSolid Lottie... | file.importLottie + Import Recent Footage + @dynamic:recentFootage + --- + Clear Recent Footage | file.clearRecentFootage Export Add to Render Queue | renderQueue.add Lottie JSON... | file.exportLottie @@ -374,6 +515,7 @@ File File... | file.replaceFootage Placeholder... | file.replaceWithPlaceholder Solid... | file.replaceWithSolid + With Layered Comp | file.replaceWithLayeredComp Reload Footage | file.reloadFootage Reveal in Finder | file.revealInFinder --- @@ -383,6 +525,8 @@ File Edit Undo | edit.undo Redo | edit.redo + History + @dynamic:history --- Quick Apply... | app.commandPalette --- @@ -494,6 +638,10 @@ Composition --- Composition Flowchart | comp.flowchart Composition Mini-Flowchart | comp.miniFlowchart + --- + VR + Create VR Environment... | comp.vr.createEnvironment + Extract Cubemap... | comp.vr.extractCubemap Layer New Text | layer.newText @@ -744,6 +892,10 @@ Effect Animation Save Animation Preset... | anim.savePreset Apply Animation Preset... | anim.applyPreset + Recent Animation Presets + @dynamic:recentPresets + --- + Clear Recent Presets | anim.clearRecentPresets Browse Presets... | anim.browsePresets Text Animation Presets Typewriter | layer.applyTextPreset {"preset":"typewriter"} @@ -902,6 +1054,8 @@ View Custom View 3 | view.3d.custom3 --- Reset 3D View | view.reset3DView + Assign Shortcut to 3D View + @dynamic:view3dShortcuts Switch to Last 3D View | view.3d.last | Escape Look at Selected Layers | view.lookAtSelected Look at All Layers | view.lookAtAll @@ -931,6 +1085,8 @@ Window Save Changes to this Workspace | window.saveWorkspace Save as New Workspace... | window.saveWorkspaceAs Edit Workspaces... | window.editWorkspaces + Assign Shortcut to Workspace + @dynamic:workspaceShortcuts --- Align | window.panel {"panel":"align"} Audio | window.panel {"panel":"audio"} | Cmd+4 @@ -963,6 +1119,7 @@ Window Project | window.panel {"panel":"project"} | Cmd+0 Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0 Timeline | window.panel {"panel":"timeline"} + @dynamic:openViewers Help EffectCraft Help... | help.docs {"page":"help"} | F1 Scripting Help... | help.docs {"page":"scripting"} @@ -971,6 +1128,10 @@ Help Animation Presets... | anim.browsePresets Keyboard Shortcuts... | app.keyboardShortcuts --- + System Compatibility Report... | help.systemReport + Enable Logging | help.enableLogging + Reveal Logging File | help.revealLogFile + --- Join the ArtCraft Discord... | help.discord Provide Feedback... | help.reportIssue --- diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index ee8790c7d..6fdbd2a72 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -6,7 +6,7 @@ fn all_entries(nodes: &[MenuNode], path: &mut Vec, out: &mut Vec<(Vec out.push((path.clone(), e.clone())), - MenuNode::Separator => {} + MenuNode::Separator | MenuNode::Dynamic { .. } => {} MenuNode::Submenu { label, children } => { path.push(label.clone()); all_entries(children, path, out); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5a2afab8d..edefe3709 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -13,6 +13,7 @@ use std::collections::BTreeMap; use effectcraft_color::Label; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -271,6 +272,13 @@ pub struct Prefs { pub scripting: Scripting, /// File ▸ Open Recent, newest first. pub recent_projects: Vec, + /// File ▸ Import Recent Footage, newest first. + pub recent_footage: Vec, + /// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest + /// first. + pub recent_presets: Vec, + /// Character panel: recently used font families, newest first. + pub recent_fonts: Vec, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -299,6 +307,9 @@ impl Default for Prefs { three_d: ThreeD::default(), scripting: Scripting::default(), recent_projects: vec![], + recent_footage: vec![], + recent_presets: vec![], + recent_fonts: vec![], extra: Extra::new(), } } @@ -428,7 +439,13 @@ impl Prefs { pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { let d = Prefs::default(); let Some(page) = page else { - *self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d }; + *self = Prefs { + recent_projects: std::mem::take(&mut self.recent_projects), + recent_footage: std::mem::take(&mut self.recent_footage), + recent_presets: std::mem::take(&mut self.recent_presets), + recent_fonts: std::mem::take(&mut self.recent_fonts), + ..d + }; return Ok(()); }; let keys: Vec<&str> = match page { @@ -488,6 +505,174 @@ impl Prefs { _ => 0.125, } } + + /// The Memory & CPU cache budgets in effect: the configured layer / footage / preview + /// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free, + /// and halved while the system is low on memory when **Reduce Cache Size When System Is Low + /// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets). + pub fn cache_budgets(&self, mem: Option) -> CacheBudgets { + let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64); + let floor = 64u64 << 20; + let mut capped = false; + let mut reduced = false; + if let Some(mem) = mem.filter(|m| m.total > 0) { + let reserved = (self.memory.ram_reserved_gb as u64) << 30; + let cap = mem.total.saturating_sub(reserved).max(3 * floor); + let sum = l + m + p; + if sum > cap { + let k = cap as f64 / sum as f64; + (l, m, p) = ((l as f64 * k) as u64, (m as f64 * k) as u64, (p as f64 * k) as u64); + capped = true; + } + if self.memory.reduce_cache_when_low && mem.is_low() { + (l, m, p) = (l / 2, m / 2, p / 2); + reduced = true; + } + } + CacheBudgets { layer: l.max(floor) as usize, media: m.max(floor) as usize, preview: p.max(floor) as usize, capped, reduced } + } + + /// Composition ▸ Motion Path: the layer-time span of a motion path to draw around `now` + /// (layer time), for keyframes at `keys` (sorted). `None` = No Motion Path. + pub fn motion_path_span(&self, keys: &[Tick], now: Tick) -> Option<(Tick, Tick)> { + let (first, last) = (*keys.first()?, *keys.last()?); + let c = &self.composition; + match c.motion_path.as_str() { + "none" => None, + "seconds" => { + let half = Tick::from_seconds_f64(c.motion_path_seconds.max(0.0) / 2.0); + let (a, b) = ((now - half).max(first), (now + half).min(last)); + (a < b).then_some((a, b)) + } + "keyframes" => { + let n = (c.motion_path_keyframes.max(1) as usize).min(keys.len()); + if n < 2 { + return None; + } + // The n keyframes centred on the current time. + let at = keys.partition_point(|k| *k < now); + let start = at.saturating_sub(n / 2).min(keys.len() - n); + Some((keys[start], keys[start + n - 1])) + } + _ => Some((first, last)), + } + } + + /// Previews ▸ Viewer Zoom Quality: smooth (bilinear) scaling of the viewer image, or nearest + /// neighbour ("Faster"). + pub fn viewer_zoom_smooth(&self) -> bool { + self.previews.zoom_quality != "faster" + } + + /// Import ▸ Interpret Unlabeled Alpha As, for footage whose file has an alpha channel that + /// doesn't state how it is stored. `None` = Ask User (keep straight and open Interpret + /// Footage); `guess` takes premultiplied for movies (codecs with alpha are usually written + /// premultiplied) and straight for stills. + pub fn unlabeled_alpha(&self, movie: bool) -> Option { + use effectcraft_project::AlphaMode::*; + Some(match self.import.unlabeled_alpha.as_str() { + "ignore" => Ignore, + "straight" => Straight, + "premultiplied" => Premultiplied, + "guess" if movie => Premultiplied, + "guess" => Straight, + _ => return None, + }) + } + + /// Import ▸ Default Drag Import As, as `file.import`'s `importAs`. + pub fn drag_import_as(&self) -> &'static str { + match self.import.drag_import_as.as_str() { + "comp" => "composition", + "compLayerSizes" => "compositionLayerSizes", + _ => "footage", + } + } + + /// Export ▸ Append Bit Depth to File Name: `Comp 1.png` → `Comp 1_16bpc.png` (sequence frame + /// runs and folders are kept). + pub fn output_name(&self, path: &str, bits: u32) -> String { + if !self.export.append_bits_to_name { + return path.to_string(); + } + let p = std::path::Path::new(path); + let (Some(stem), dir) = (p.file_stem().map(|s| s.to_string_lossy().to_string()), p.parent()) else { return path.to_string() }; + let tag = format!("_{bits}bpc"); + if stem.ends_with(&tag) { + return path.to_string(); + } + let name = match p.extension() { + Some(e) => format!("{stem}{tag}.{}", e.to_string_lossy()), + None => format!("{stem}{tag}"), + }; + match dir.filter(|d| !d.as_os_str().is_empty()) { + Some(d) => d.join(name).to_string_lossy().to_string(), + None => name, + } + } + + /// Export ▸ Segment Sequences / Segment Movie Files: how many frames go in each segment of an + /// output of `frames` frames (`None` = one file / folder). Sequences split every Files per + /// Segment frames; movies every Segment Size MB at the output's data rate + /// (`bytes_per_frame`, estimated from the format's bitrate). + pub fn segment_frames(&self, frames: u64, sequence: bool, bytes_per_frame: f64) -> Option { + let e = &self.export; + let per = if sequence { + e.segment_sequences.then_some(e.segment_sequence_files.max(1) as u64)? + } else { + if !e.segment_movies || bytes_per_frame <= 0.0 { + return None; + } + (((e.segment_movie_mb.max(1) as f64) * 1_048_576.0 / bytes_per_frame).floor() as u64).max(1) + }; + (per < frames).then_some(per) + } + + /// Remember a font in the Character panel's recent fonts (Type ▸ Number of Recent Fonts). + pub fn push_recent_font(&mut self, family: &str) { + push_mru(&mut self.recent_fonts, family, 30); + } + + /// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display). + pub fn recent_fonts_shown(&self) -> &[String] { + let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len()); + &self.recent_fonts[..n] + } + + /// Remember imported footage for File ▸ Import Recent Footage. + pub fn push_recent_footage(&mut self, path: &str) { + push_mru(&mut self.recent_footage, path, self.general.recent_items as usize); + } + + /// Remember an applied animation preset for Animation ▸ Recent Animation Presets. + pub fn push_recent_preset(&mut self, path: &str) { + push_mru(&mut self.recent_presets, path, self.general.recent_items as usize); + } +} + +fn push_mru(list: &mut Vec, v: &str, max: usize) { + list.retain(|x| x != v); + list.insert(0, v.to_string()); + list.truncate(max.max(1)); +} + +/// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct CacheBudgets { + pub layer: usize, + pub media: usize, + pub preview: usize, + /// Scaled down to leave the reserved RAM free. + pub capped: bool, + /// Halved because the system is low on memory. + pub reduced: bool, +} + +/// Colour of the `k`-th mask of a layer: Appearance ▸ Cycle Mask Colors cycles through the mask +/// colours, otherwise every new mask takes the first one. +pub fn mask_color(cycle: bool, k: usize) -> [u8; 3] { + use effectcraft_project::build::MASK_COLORS; + if cycle { MASK_COLORS[k % MASK_COLORS.len()] } else { MASK_COLORS[0] } } fn label_index(l: Label) -> Option { @@ -657,13 +842,14 @@ pub fn pages() -> Vec { Section("Options"), s("general.showToolTips", "Show Tool Tips", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), - s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false), + s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), - s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false), - s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false), - s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false), - s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false), + s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), + s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, true), + s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, true), + s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, true), s("general.useSystemColorPicker", "Use System Color Picker", B, false), + Note("EffectCraft always uses its own colour picker (the same on every platform and the web)."), ], }, Page { @@ -671,7 +857,7 @@ pub fn pages() -> Vec { title: "Startup & Repair", items: vec![ s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true), - s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false), + s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, true), s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, @@ -710,13 +896,14 @@ pub fn pages() -> Vec { ("No More Than (Seconds)", "seconds"), ("No More Than (Keyframes)", "keyframes"), ]), - false, + true, ), - s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false), - s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false), + s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), true), + s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), true), Section("Display"), s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true), s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false), + Note("Panels are always drawn by the GPU (wgpu)."), ], }, Page { @@ -724,10 +911,10 @@ pub fn pages() -> Vec { title: "Previews", items: vec![ s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true), - s("previews.showInternalWireframes", "Show Internal Wireframes", B, false), + s("previews.showInternalWireframes", "Show Internal Wireframes", B, true), s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true), s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true), - s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false), + s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), true), Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" }, ], }, @@ -739,9 +926,9 @@ pub fn pages() -> Vec { s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true), Section("Labels and Colors"), s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true), - s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false), - s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false), - s("appearance.useGradients", "Use Gradients", B, false), + s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true), + s("appearance.cycleMaskColors", "Cycle Mask Colors", B, true), + s("appearance.useGradients", "Use Gradients", B, true), Section("Menu Bar"), s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true), ], @@ -754,13 +941,13 @@ pub fn pages() -> Vec { s("grids.gridColor", "Color", Kind::Color, true), s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true), s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true), - s("grids.gridStyle", "Style", Kind::Choice(STYLES), false), + s("grids.gridStyle", "Style", Kind::Choice(STYLES), true), Section("Proportional Grid"), - s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false), - s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false), + s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), true), + s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), true), Section("Guides"), s("grids.guideColor", "Color", Kind::Color, true), - s("grids.guideStyle", "Style", Kind::Choice(STYLES), false), + s("grids.guideStyle", "Style", Kind::Choice(STYLES), true), Section("Safe Margins"), s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true), s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true), @@ -771,10 +958,10 @@ pub fn pages() -> Vec { id: "type", title: "Type", items: vec![ - s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false), - s("type.fontPreview", "Show Font Preview", B, false), - s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false), - s("type.fontNamesInEnglish", "Show Font Names in English", B, false), + s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), true), + s("type.fontPreview", "Show Font Preview", B, true), + s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), true), + s("type.fontNamesInEnglish", "Show Font Names in English", B, true), ], }, Page { @@ -784,7 +971,7 @@ pub fn pages() -> Vec { s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true), s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true), s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true), - s("import.reportMissingFrames", "Report Missing Frames", B, false), + s("import.reportMissingFrames", "Report Missing Frames", B, true), s( "import.unlabeledAlpha", "Interpret Unlabeled Alpha As", @@ -795,13 +982,13 @@ pub fn pages() -> Vec { ("Straight", "straight"), ("Premultiplied", "premultiplied"), ]), - false, + true, ), s( "import.dragImportAs", "Default Drag Import As", Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]), - false, + true, ), ], }, @@ -809,12 +996,12 @@ pub fn pages() -> Vec { id: "export", title: "Export", items: vec![ - s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false), - s("export.segmentSequences", "Segment Sequences", B, false), - s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false), - s("export.segmentMovies", "Segment Movie Files", B, false), - s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false), - s("export.appendBitsToName", "Append Bit Depth to File Name", B, false), + s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, true), + s("export.segmentSequences", "Segment Sequences", B, true), + s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), true), + s("export.segmentMovies", "Segment Movie Files", B, true), + s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), true), + s("export.appendBitsToName", "Append Bit Depth to File Name", B, true), ], }, Page { @@ -823,7 +1010,7 @@ pub fn pages() -> Vec { items: vec![ Section("Audio Hardware"), AudioDevices { key: "audio.outputDevice" }, - s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false), + s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), true), Section("Audio Output Mapping"), s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true), s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true), @@ -834,36 +1021,36 @@ pub fn pages() -> Vec { title: "Disk", items: vec![ Section("Disk Cache"), - s("disk.diskCacheEnabled", "Enable Disk Cache", B, false), - s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false), - s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false), + s("disk.diskCacheEnabled", "Enable Disk Cache", B, true), + s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), true), + s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, true), Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# }, Section("Media Cache"), - s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false), - s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false), + s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, true), + s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, true), ], }, Page { id: "memory", title: "Memory & CPU", items: vec![ - s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false), + s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), true), Section("Cache Budgets"), s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true), - s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false), + s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true), ], }, Page { id: "video", title: "Video", items: vec![ - s("video.enableOutput", "Enable Video Preview Output", B, false), - s("video.device", "Video Device", Kind::Text, false), - s("video.outputDuringPlayback", "Video Output During Playback", B, false), - s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false), - s("video.disableWhenBackground", "Disable Video Output When in Background", B, false), + s("video.enableOutput", "Enable Video Preview Output", B, true), + s("video.device", "Video Device", Kind::Choice(&[("Floating Window", ""), ("Full Screen", "fullscreen")]), true), + s("video.outputDuringPlayback", "Video Output During Playback", B, true), + s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, true), + s("video.disableWhenBackground", "Disable Video Output When in Background", B, true), ], }, Page { @@ -871,9 +1058,9 @@ pub fn pages() -> Vec { title: "3D", items: vec![ s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true), - s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false), - s("threeD.extendedViewer", "Extended Viewer", B, false), - s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false), + s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, true), + s("threeD.extendedViewer", "Extended Viewer", B, true), + s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, true), ], }, Page { @@ -881,18 +1068,18 @@ pub fn pages() -> Vec { title: "Scripting & Expressions", items: vec![ Section("Application Scripting"), - s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false), - s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false), + s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, true), + s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, true), s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false), Section("Expressions Editor"), - s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false), - s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false), - s("scripting.lineNumbers", "Line Numbers", B, false), - s("scripting.autoComplete", "Auto-complete", B, false), - s("scripting.bracketMatching", "Bracket Matching", B, false), - s("scripting.wordWrap", "Word Wrap", B, false), - s("scripting.errorBanner", "Show Expression Error Banner", B, false), - Note("Expressions use the JavaScript engine."), + s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), true), + s("scripting.syntaxHighlighting", "Syntax Highlighting", B, true), + s("scripting.lineNumbers", "Line Numbers", B, true), + s("scripting.autoComplete", "Auto-complete", B, true), + s("scripting.bracketMatching", "Bracket Matching", B, true), + s("scripting.wordWrap", "Word Wrap", B, true), + s("scripting.errorBanner", "Show Expression Error Banner", B, true), + Note("Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger."), ], }, ] diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index bc490f472..31325f4a6 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; +use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; use effectcraft_project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_render::{ExprHost, FootageSource}; use serde::Serialize; @@ -27,6 +27,8 @@ pub struct ExportJob<'a> { pub accel: Option<&'a dyn effectcraft_render::Accelerator>, /// Resolved output path (templates expanded except the `#` frame-number run). pub path: &'a str, + /// Settings ▸ General ▸ Switches Affect Nested Comps. + pub nested_switches: bool, } /// What an export wrote. @@ -124,8 +126,56 @@ struct Work { expr: Option>, accel: Option>, exporter: Arc, - /// Per queue item: one (item with that output module, resolved path) per output module. + /// Per queue item: one (item with that output module, resolved path) per output module + /// (and per segment, see [`Session::segments`]). items: Vec>, + /// Settings ▸ General ▸ Switches Affect Nested Comps. + nested_switches: bool, +} + +/// Bits per channel an output format writes for a project bit depth (named in the file when +/// Settings ▸ Export ▸ Append Bit Depth to File Name is on). +pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { + use effectcraft_project::BitDepth; + let project = match depth { + BitDepth::Bpc8 => 8, + BitDepth::Bpc16 => 16, + BitDepth::Bpc32 => 32, + }; + match format { + OutputFormat::ExrSequence => 32, + OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16), + OutputFormat::ProRes => 10, + OutputFormat::Wav | OutputFormat::Aiff => 16, + _ => 8, + } +} + +/// The bytes an output writes per frame, estimated from its data rate (H.264 bitrate, Apple's +/// published ProRes target rates at 1080p29.97 scaled by frame size, typical rates otherwise). +pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 { + use effectcraft_project::render_queue::ProResProfile::*; + let fps = fps.max(1.0); + let px = (w as f64 * h as f64) / (1920.0 * 1080.0); + let mbps = |m: f64| m * 1_000_000.0 / 8.0; + match om.format { + OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps, + OutputFormat::ProRes => { + let rate = match om.prores_profile { + Proxy => 45.0, + Lt => 102.0, + Standard => 147.0, + Hq => 220.0, + P4444 => 330.0, + P4444Xq => 500.0, + }; + mbps(rate) * px / 29.97 + } + OutputFormat::WebM => mbps(8.0) * px / fps, + OutputFormat::Gif => w as f64 * h as f64 * 0.5, + OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps, + _ => w as f64 * h as f64 * 3.0, + } } fn unix_now() -> u64 { @@ -158,7 +208,15 @@ fn run_work(w: Work, shared: &JobShared) { // first module's file is the item's output. let mut r: Result = Err("no output module".into()); for (mi, (mitem, path)) in modules.iter().enumerate() { - let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path }; + let job = ExportJob { + project: &w.project, + footage: w.footage.as_ref(), + expr: w.expr.as_deref(), + accel: w.accel.as_deref(), + item: mitem, + path, + nested_switches: w.nested_switches, + }; let rr = w.exporter.export(&job, &mut |done, total| { let mut s = lock(&shared.state); s.done = done; @@ -228,12 +286,58 @@ impl Session { if path.is_absolute() { return Some(p); } - let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()); + // Settings ▸ Export ▸ Default Output Folder, else the project's folder. + let folder = self.prefs.export.default_output_folder.trim(); + let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); + let base = + base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) } + /// Settings ▸ Export ▸ Segment Sequences / Segment Movie Files: the (item, path) outputs one + /// output module writes. Sequences go into numbered folders of Files per Segment frames each + /// (`out/Comp 1_001/Comp 1_00000.png`…); movies into numbered files of about Segment Size MB + /// each (`Comp 1_001.mp4`…), cut at the frame count the output's data rate gives. + pub fn segments(&self, item: &RenderQueueItem, path: &str) -> Vec<(RenderQueueItem, String)> { + let Some(comp) = self.project.comp(item.comp) else { return vec![(item.clone(), path.to_string())] }; + let fmt = item.output.format; + let frames = item.settings.frame_count(comp); + let (w, h) = item.settings.output_size(comp); + let bpf = bytes_per_frame(&item.output, w, h, item.settings.rate(comp).as_f64()); + let Some(per) = self.prefs.segment_frames(frames, fmt.is_sequence(), bpf) else { return vec![(item.clone(), path.to_string())] }; + let p = std::path::Path::new(path); + let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let ext = p.extension().map(|s| s.to_string_lossy().to_string()); + let rate = item.settings.rate(comp); + let f0 = item.settings.first_frame(comp); + let mut out = vec![]; + let mut i = 0; + let mut k = 1; + while i < frames { + let n = per.min(frames - i); + let mut m = item.clone(); + let (start, end) = (rate.tick_of(f0 + i as i64), rate.tick_of(f0 + (i + n) as i64)); + m.settings.time_span = effectcraft_project::render_queue::TimeSpan::Custom { start, end }; + let seg = if fmt.is_sequence() { + let base = stem.trim_end_matches(['#', '[', ']', '_']).to_string(); + let folder = dir.join(format!("{base}_{k:03}")); + folder.join(p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()) + } else { + dir.join(match &ext { + Some(e) => format!("{stem}_{k:03}.{e}"), + None => format!("{stem}_{k:03}"), + }) + }; + out.push((m, seg.to_string_lossy().to_string())); + i += n; + k += 1; + } + out + } + /// Whether a render job is running. pub fn is_rendering(&self) -> bool { self.render_job.as_ref().is_some_and(|j| !j.is_finished()) @@ -264,7 +368,13 @@ impl Session { let mut m = it.clone(); m.output = om.clone(); match self.resolve_output(&m) { - Some(p) if formats.contains(&om.format) => modules.push((m, p)), + Some(p) if formats.contains(&om.format) => { + // Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences / + // Movie Files. + let bits = output_bits(om.format, self.project.settings.bit_depth); + let p = self.prefs.output_name(&p, bits); + modules.extend(self.segments(&m, &p)); + } Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))), None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())), None => problem = Some(RenderStatus::NeedsOutput), @@ -291,7 +401,15 @@ impl Session { return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into()); } let ids: Vec = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect(); - let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items }; + let work = Work { + project: self.project.clone(), + footage: self.footage.clone(), + expr: self.expr.clone(), + accel: self.accel.clone(), + exporter, + items, + nested_switches: self.prefs.general.switches_affect_nested_comps, + }; let shared = Arc::new(JobShared::default()); if wait { run_work(work, &shared); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index e54eb93bb..1d433100b 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -25,9 +25,15 @@ impl Session { /// Settings changed: apply what the engine owns (cache budgets) and tell frontends. pub fn prefs_changed(&mut self) { self.prefs.normalize(); - self.layer_cache.set_budget(self.prefs.layer_cache_bytes()); - self.footage.set_cache_budget(self.prefs.media_cache_bytes()); + self.apply_cache_budgets(); self.configure_disk_cache(); + self.footage.set_conform_folder(self.conformed_audio_folder()); + // Switches Affect Nested Comps changes what precomp layers render: drop cached pixels. + let nested = self.prefs.general.switches_affect_nested_comps; + if self.applied_nested_switches.replace(nested).is_some_and(|was| was != nested) { + self.layer_cache.clear(); + self.events.push(crate::Event::PurgeCaches); + } // Fewer undo levels apply right away. let levels = self.prefs.general.undo_levels.max(1) as usize; if self.history.undo.len() > levels { @@ -37,6 +43,53 @@ impl Session { self.prefs_revision += 1; } + /// A Disk settings folder: the setting, or (for frontends with a settings store) `name` next + /// to the platform disk cache folder. Headless sessions without the setting stay off disk. + fn cache_folder(&self, setting: &str, name: &str) -> Option { + let s = setting.trim(); + if !s.is_empty() { + return Some(std::path::PathBuf::from(s)); + } + let base = effectcraft_render::disk_cache::default_folder(); + self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) + } + + /// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries). + pub fn media_cache_folder(&self) -> Option { + self.cache_folder(&self.prefs.disk.media_cache_folder, "Media Cache") + } + + /// Settings ▸ Disk ▸ Conformed Audio Folder (decoded footage audio, see + /// `MediaPool::set_conform_folder`). + pub fn conformed_audio_folder(&self) -> Option { + self.cache_folder(&self.prefs.disk.conformed_media_folder, "Conformed Audio") + } + + /// Apply the Memory & CPU cache budgets, limited by RAM Reserved for Other Applications and + /// reduced while the system is low on memory (see [`crate::prefs::Prefs::cache_budgets`]). + pub fn apply_cache_budgets(&mut self) { + let b = self.prefs.cache_budgets(self.sys_memory); + self.layer_cache.set_budget(b.layer); + self.footage.set_cache_budget(b.media); + } + + /// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply + /// the cache budgets when the reading changes them. Returns the effective budgets. + pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { + let before = self.prefs.cache_budgets(self.sys_memory); + self.sys_memory = crate::sysinfo::memory().or(self.sys_memory); + let after = self.prefs.cache_budgets(self.sys_memory); + if after != before { + self.apply_cache_budgets(); + // Frontends re-apply their preview cache budget. + self.prefs_revision += 1; + if after.reduced && !before.reduced { + self.toast("The system is low on memory: cache sizes were reduced"); + } + } + after + } + /// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache /// and attach it to the layer cache. Without a folder setting the platform cache folder is /// used, but only by frontends with a settings store (headless sessions stay off disk). diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs new file mode 100644 index 000000000..0c79c2a11 --- /dev/null +++ b/crates/engine/src/sysinfo.rs @@ -0,0 +1,140 @@ +//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing +//! caches when the system is low on memory) and Help ▸ System Compatibility Report. +//! +//! Pure Rust and dependency-free: the values come from the operating system's own reporting +//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every +//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their +//! configured budgets. + +use serde::Serialize; + +/// Physical memory in bytes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct SysMemory { + pub total: u64, + /// Memory the system can hand out without swapping (free + reclaimable caches). + pub available: u64, +} + +impl SysMemory { + /// The system is low on memory: less than 10% (or less than 1 GB) is available. + pub fn is_low(&self) -> bool { + self.total > 0 && (self.available < self.total / 10 || self.available < 1 << 30) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +fn run(cmd: &str, args: &[&str]) -> Option { + let out = std::process::Command::new(cmd).args(args).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Parse `/proc/meminfo` (kB values). +pub fn parse_meminfo(text: &str) -> Option { + let field = |name: &str| -> Option { + let line = text.lines().find(|l| l.starts_with(name))?; + let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) + }; + let total = field("MemTotal:")?; + let available = field("MemAvailable:").or_else(|| Some(field("MemFree:")? + field("Cached:").unwrap_or(0)))?; + Some(SysMemory { total, available }) +} + +/// Parse macOS `vm_stat` output: free + inactive + speculative + purgeable pages. +pub fn parse_vm_stat(text: &str, total: u64) -> Option { + let page: u64 = text.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?; + let pages = |name: &str| -> u64 { + text.lines() + .find(|l| l.starts_with(name)) + .and_then(|l| l.rsplit(':').next()) + .and_then(|v| v.trim().trim_end_matches('.').parse::().ok()) + .unwrap_or(0) + }; + let free = pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable"); + Some(SysMemory { total, available: (free * page).min(total) }) +} + +/// Total and available physical memory, when the system reports them. +pub fn memory() -> Option { + #[cfg(target_os = "linux")] + { + return parse_meminfo(&std::fs::read_to_string("/proc/meminfo").ok()?); + } + #[cfg(target_os = "macos")] + { + let total: u64 = run("sysctl", &["-n", "hw.memsize"])?.trim().parse().ok()?; + return parse_vm_stat(&run("vm_stat", &[])?, total).or(Some(SysMemory { total, available: total })); + } + #[cfg(target_os = "windows")] + { + let text = run( + "powershell", + &["-NoProfile", "-Command", "$o=Get-CimInstance Win32_OperatingSystem; \"$($o.TotalVisibleMemorySize) $($o.FreePhysicalMemory)\""], + )?; + let mut it = text.split_whitespace().filter_map(|v| v.parse::().ok()); + return Some(SysMemory { total: it.next()? * 1024, available: it.next()? * 1024 }); + } + #[allow(unreachable_code)] + None +} + +/// CPU model name. +pub fn cpu_name() -> Option { + #[cfg(target_os = "linux")] + { + let t = std::fs::read_to_string("/proc/cpuinfo").ok()?; + return t.lines().find(|l| l.starts_with("model name")).and_then(|l| l.split(':').nth(1)).map(|s| s.trim().to_string()); + } + #[cfg(target_os = "macos")] + { + return run("sysctl", &["-n", "machdep.cpu.brand_string"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + } + #[cfg(target_os = "windows")] + { + return std::env::var("PROCESSOR_IDENTIFIER").ok(); + } + #[allow(unreachable_code)] + None +} + +/// Operating system name and version. +pub fn os_version() -> String { + #[cfg(target_os = "macos")] + if let Some(v) = run("sw_vers", &["-productVersion"]) { + return format!("macOS {}", v.trim()); + } + #[cfg(target_os = "linux")] + if let Ok(t) = std::fs::read_to_string("/etc/os-release") + && let Some(n) = t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")) + { + return n.trim_matches('"').to_string(); + } + #[cfg(target_os = "windows")] + if let Some(v) = run("cmd", &["/C", "ver"]) { + return v.trim().to_string(); + } + format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH) +} + +/// Logical CPU cores. +pub fn cpu_cores() -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_proc_meminfo_and_vm_stat() { + let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap(); + assert_eq!(m, SysMemory { total: 16_000_000 * 1024, available: 8_000_000 * 1024 }); + assert!(!m.is_low()); + let vm = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 1000.\nPages active: 5.\nPages inactive: 3000.\nPages speculative: 100.\n"; + let m = parse_vm_stat(vm, 8 << 30).unwrap(); + assert_eq!(m.available, 4100 * 16384); + assert!(m.is_low()); + } +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs new file mode 100644 index 000000000..cc6a081cd --- /dev/null +++ b/crates/engine/src/tests_m145.rs @@ -0,0 +1,666 @@ +//! M14.5: settings that change behaviour, the keyframe assistants and labels, and the menu +//! entries added for After Effects parity (History, recent footage / presets, XML copies, VR, +//! shortcut slots, logging and the compatibility report). + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::render::RenderOpts; +use crate::{KeyRef, Session}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{ItemId, LayerId}; +use effectcraft_time::Tick; + +fn tmp(name: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let d = std::env::temp_dir().join(format!("ec-m145-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "duration": 2, "frameRate": 10})).unwrap(); + s +} + +fn solid(s: &mut Session, color: &str) -> u64 { + s.execute("layer.newSolid", json!({"color": color, "width": 32, "height": 24})).unwrap()["layer"].as_u64().unwrap() +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +// ---------------------------------------------------------------- General + +#[test] +fn split_layers_go_above_or_below_the_original() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![a, below], "off (default): the new part goes below"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap(); + let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![above, a]); +} + +#[test] +fn new_masks_cycle_colours_only_when_asked() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let colors = |s: &Session| -> Vec<[u8; 3]> { + layer(s, a) + .props + .sub("masks") + .unwrap() + .groups() + .map(|g| match g.kind { + effectcraft_project::GroupKind::Mask { color, .. } => color, + _ => [0; 3], + }) + .collect() + }; + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + let c = colors(&s); + assert_ne!(c[0], c[1], "cycling by default"); + s.execute("prefs.set", json!({"key": "appearance.cycleMaskColors", "value": false})).unwrap(); + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + s.execute("mask.new", json!({"layer": a, "vertices": [[0, 0], [5, 5]]})).unwrap(); + let c = colors(&s); + assert_eq!(c[2], c[0]); + assert_eq!(c[3], c[0]); +} + +#[test] +fn preserve_constant_vertex_count_edits_every_mask_key() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let m = s.execute("layer.addMask", json!({"layer": a})).unwrap()["mask"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": a, "prop": layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid})).unwrap(); + let path_uid = layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid; + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": path_uid})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": a, "mask": m, "index": 0, "point": [1, 1]})).unwrap(); + let counts = |s: &Session| -> Vec { + layer(s, a) + .props + .find(path_uid) + .unwrap() + .keys + .iter() + .map(|k| match &k.value { + KV::Path(p) => p.vertices.len(), + _ => 0, + }) + .collect() + }; + assert_eq!(counts(&s), vec![4, 4]); + // On (the default): a vertex added at one key is added to the other. + s.execute("mask.insertVertex", json!({"layer": a, "mask": m, "segment": 0})).unwrap(); + assert_eq!(counts(&s), vec![5, 5]); + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(a), mask: m, index: 1 }]; + s.execute("mask.deleteVertices", json!({})).unwrap(); + assert_eq!(counts(&s), vec![4, 4]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(counts(&s), vec![5, 5], "one undo step"); + // Off: only the current key changes. + s.execute("prefs.set", json!({"key": "general.preserveConstantVertexCount", "value": false})).unwrap(); + s.execute("mask.addVertex", json!({"layer": a, "mask": m, "point": [3, 3]})).unwrap(); + assert_eq!(counts(&s), vec![5, 6]); +} + +#[test] +fn switches_affect_nested_comps_passes_quality_down() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let a = solid(&mut s, "#ff8040"); + // A rotated, scaled layer: bilinear (Best) and nearest (Draft) sampling differ. + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 17})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [131, 131]})).unwrap(); + let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let pre = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.quality", json!({"layers": [pre], "quality": "draft"})).unwrap(); + let draw = |s: &Session| s.render(ItemId(outer), Tick::ZERO, RenderOpts::default()); + let on = draw(&s); + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap(); + let off = draw(&s); + assert_ne!(on.data, off.data, "the precomp's Draft switch reaches the nested layer only when on"); + // On = the nested layer itself set to Draft. + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": true})).unwrap(); + s.open_comp(ItemId(inner)); + s.execute("layer.quality", json!({"layers": [a], "quality": "draft"})).unwrap(); + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap(); + assert_eq!(draw(&s).data, on.data); +} + +#[test] +fn related_comps_share_the_current_time() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap(); + let outer = s.execute("comp.new", json!({"name": "Outer", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap(); + let l = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTiming", json!({"layer": l, "startTime": 2.0})).ok(); + s.edit("t", None, |p, _| { + p.comp_mut(ItemId(outer)).unwrap().layer_mut(LayerId(l)).unwrap().start_time = Tick::from_seconds_f64(2.0); + Ok(()) + }) + .unwrap(); + s.set_time(Tick::from_seconds_f64(5.0)); + assert!((s.time_of(ItemId(inner)).seconds() - 3.0).abs() < 1e-9, "nested comp follows through the layer's start"); + s.open_comp(ItemId(inner)); + s.set_time(Tick::from_seconds_f64(1.0)); + assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9, "and the other way"); + s.execute("prefs.set", json!({"key": "general.syncTimeRelatedItems", "value": false})).unwrap(); + s.set_time(Tick::from_seconds_f64(7.0)); + assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9); +} + +#[test] +fn pick_whip_writes_match_names_without_compact_english() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": r, "effect": "Gaussian Blur"})).unwrap(); + s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap(); + let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/opacity", "target": {"layer": r, "path": "transform/opacity"}})).unwrap(); + assert_eq!(e, json!("thisComp.layer(\"Other\")(\"transform\")(\"opacity\")")); + let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/rotation", "target": {"layer": a, "path": "transform/opacity"}})).unwrap(); + assert_eq!(e, json!("thisLayer(\"transform\")(\"opacity\")")); +} + +// ---------------------------------------------------------------- pure setting helpers + +#[test] +fn cache_budgets_leave_reserved_ram_and_shrink_when_low() { + use crate::sysinfo::SysMemory; + let mut p = crate::prefs::Prefs::default(); + p.memory.layer_cache_mb = 4096; + p.memory.media_cache_mb = 4096; + p.memory.preview_cache_mb = 8192; + p.memory.ram_reserved_gb = 6; + let b = p.cache_budgets(None); + assert_eq!((b.layer >> 20, b.preview >> 20, b.capped), (4096, 8192, false)); + // 16 GB machine, 6 GB reserved: 16 GB of caches scaled into 10 GB. + let m = SysMemory { total: 16 << 30, available: 12 << 30 }; + let b = p.cache_budgets(Some(m)); + assert!(b.capped && !b.reduced); + assert!(((b.layer + b.media + b.preview) as i64 - (10i64 << 30)).abs() < 4, "{b:?}"); + // Low on memory: halved; off: not. + let low = SysMemory { total: 16 << 30, available: 512 << 20 }; + let r = p.cache_budgets(Some(low)); + assert!(r.reduced && r.preview < b.preview / 2 + 2); + p.memory.reduce_cache_when_low = false; + assert!(!p.cache_budgets(Some(low)).reduced); +} + +#[test] +fn motion_path_span_follows_the_composition_setting() { + let mut p = crate::prefs::Prefs::default(); + let s = |x: f64| Tick::from_seconds_f64(x); + let keys: Vec = (0..40).map(|i| s(i as f64)).collect(); + // Default: no more than 15 keyframes, centred on the current time. + assert_eq!(p.motion_path_span(&keys, s(20.0)), Some((s(13.0), s(27.0)))); + assert_eq!(p.motion_path_span(&keys, s(0.0)), Some((s(0.0), s(14.0)))); + p.composition.motion_path = "all".into(); + assert_eq!(p.motion_path_span(&keys, s(5.0)), Some((s(0.0), s(39.0)))); + p.composition.motion_path = "none".into(); + assert_eq!(p.motion_path_span(&keys, s(5.0)), None); + p.composition.motion_path = "seconds".into(); + p.composition.motion_path_seconds = 4.0; + assert_eq!(p.motion_path_span(&keys, s(10.0)), Some((s(8.0), s(12.0)))); +} + +#[test] +fn import_export_helpers() { + use effectcraft_project::AlphaMode; + let mut p = crate::prefs::Prefs::default(); + assert_eq!(p.unlabeled_alpha(false), None, "Ask User"); + for (v, movie, want) in [ + ("ignore", false, AlphaMode::Ignore), + ("premultiplied", false, AlphaMode::Premultiplied), + ("guess", true, AlphaMode::Premultiplied), + ("guess", false, AlphaMode::Straight), + ] { + p.import.unlabeled_alpha = v.into(); + assert_eq!(p.unlabeled_alpha(movie), Some(want)); + } + assert_eq!(p.drag_import_as(), "footage"); + p.import.drag_import_as = "compLayerSizes".into(); + assert_eq!(p.drag_import_as(), "compositionLayerSizes"); + assert_eq!( + super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(), + "3 missing frames (3–4, 6)" + ); + assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none()); + // 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!(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); + p.export.segment_sequences = true; + p.export.segment_sequence_files = 30; + assert_eq!(p.segment_frames(100, true, 0.0), Some(30)); + p.export.segment_movies = true; + p.export.segment_movie_mb = 1; + assert_eq!(p.segment_frames(100, false, 1_048_576.0 / 10.0), Some(10)); + assert_eq!(p.segment_frames(5, false, 1_048_576.0 / 10.0), None); + // Recent fonts. + for f in ["A", "B", "C", "A"] { + p.push_recent_font(f); + } + p.type_.recent_fonts = 2; + assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]); +} + +#[test] +fn render_queue_uses_default_folder_bit_depth_names_and_segments() { + use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; + let mut s = session(); + solid(&mut s, "#ff0000"); + let dir = tmp("out"); + s.execute("prefs.set", json!({"values": {"export.defaultOutputFolder": dir.to_string_lossy(), "export.appendBitsToName": true}})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let mut item = RenderQueueItem::new(1, cid); + item.output.output = "Main.png".into(); + item.output.format = OutputFormat::PngSequence; + let out = s.resolve_output(&item).unwrap(); + assert_eq!(PathBuf::from(&out), dir.join("Main.png")); + assert_eq!( + s.prefs.output_name(&out, crate::render_queue::output_bits(OutputFormat::PngSequence, s.project.settings.bit_depth)), + dir.join("Main_8bpc.png").to_string_lossy() + ); + // 20 frames in segments of 8 files: three folders, spans tiling the work area. + s.prefs.export.segment_sequences = true; + s.prefs.export.segment_sequence_files = 8; + let segs = s.segments(&item, &out); + assert_eq!(segs.len(), 3); + let comp = s.active_comp().unwrap().clone(); + let n: Vec = segs.iter().map(|(i, _)| i.settings.frame_count(&comp)).collect(); + assert_eq!(n, vec![8, 8, 4]); + assert!(segs[1].1.contains("Main_002"), "{}", segs[1].1); + assert_eq!(segs[2].0.settings.first_frame(&comp), 16); + // Movies: a ~1 MB file per segment at the H.264 bitrate. + s.prefs.export.segment_movies = true; + s.prefs.export.segment_movie_mb = 1; + item.output.format = OutputFormat::H264; + item.output.bitrate_kbps = 4000; // 50 kB per frame at 10 fps + let segs = s.segments(&item, "/o/Main.mp4"); + assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB"); + item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB + let segs = s.segments(&item, "/o/Main.mp4"); + assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]); +} + +#[test] +fn every_wired_setting_is_live_and_display_only_ones_are_documented() { + let todo = crate::prefs::todo_keys(); + assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]); + let docs = include_str!("../../../docs/preferences.md"); + assert!(docs.contains("### Display-only settings")); + assert!(!docs.contains("Not wired yet")); +} + +// ---------------------------------------------------------------- scripting gate + +#[test] +fn execute_file_needs_the_gate_and_asks_first() { + let mut s = session(); + let f = tmp("run.txt"); + std::fs::write(&f, "x").unwrap(); + let p = json!({"path": f.to_string_lossy()}); + assert!(s.execute("file.executeFile", p.clone()).is_err(), "off by default"); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + let r = s.execute("file.executeFile", p.clone()).unwrap(); + assert!(r.get("needsConfirmation").is_some()); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.confirmExecute"))); + let r = s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap(); + assert!(r.get("opened").is_some()); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.starts_with("file://")))); + s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap(); + assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some()); +} + +// ---------------------------------------------------------------- Edit ▸ History, recent lists + +#[test] +fn history_submenu_jumps_back_several_steps() { + let mut s = session(); + solid(&mut s, "#ff0000"); + solid(&mut s, "#00ff00"); + solid(&mut s, "#0000ff"); + let list = s.execute("edit.history", json!({})).unwrap(); + assert_eq!(list["undo"][0], json!("New Solid")); + let n = s.history.undo.len(); + let (entries, _) = crate::menus::dynamic(&s, "history", &Default::default()); + assert_eq!(entries.len(), n); + assert_eq!(entries[1].params, json!({"steps": 2})); + s.execute("edit.history", json!({"steps": 2})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.history.redo.len(), 2); + s.execute("edit.history", json!({"redo": 2})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 3); + assert!(s.execute("edit.history", json!({"steps": 999})).is_err()); +} + +#[test] +fn recent_footage_and_presets_are_remembered() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + // Imports are remembered even when the build has no decoder (the error comes after). + let _ = s.execute("file.import", json!({"paths": ["/x/clip.mov", "/x/still.png"]})); + assert_eq!(s.prefs.recent_footage, ["/x/still.png", "/x/clip.mov"]); + let (e, _) = crate::menus::dynamic(&s, "recentFootage", &Default::default()); + assert_eq!(e.iter().map(|e| e.label.as_str()).collect::>(), ["still.png", "clip.mov"]); + s.execute("file.clearRecentFootage", json!({})).unwrap(); + assert_eq!(crate::menus::dynamic(&s, "recentFootage", &Default::default()), (vec![], Some("No Recent Footage"))); + // Presets. + let path = tmp("fade.ecpreset"); + s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("anim.savePreset", json!({"path": path})).unwrap(); + s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap(); + assert_eq!(s.prefs.recent_presets, [path.to_string_lossy().to_string()]); + let (e, _) = crate::menus::dynamic(&s, "recentPresets", &Default::default()); + assert_eq!(e[0].label, "fade"); + s.state.selected_layers = vec![LayerId(a)]; + s.execute("anim.applyRecentPreset", json!({"index": 0})).unwrap(); + s.execute("anim.clearRecentPresets", json!({})).unwrap(); + assert!(s.prefs.recent_presets.is_empty()); + // Recent lists survive a settings round trip and a reset. + s.prefs.push_recent_footage("/y/a.wav"); + let back = crate::prefs::Prefs::from_json(&s.prefs.to_json()); + assert_eq!(back.recent_footage, ["/y/a.wav"]); + s.execute("prefs.reset", json!({})).unwrap(); + assert_eq!(s.prefs.recent_footage, ["/y/a.wav"]); +} + +#[test] +fn save_a_copy_as_xml_opens_again() { + let mut s = session(); + solid(&mut s, "#ff0000"); + let path = tmp("copy.ecprojx"); + s.execute("file.saveCopyAsXml", json!({"path": path.to_string_lossy()})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with(" = r["faces"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(faces.len(), 6); + for f in &faces { + let c = s.project.comp(ItemId(*f)).unwrap(); + assert_eq!((c.width, c.height), (64, 64)); + assert!(c.layers.iter().any(|l| l.is_camera())); + assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers"); + let _ = main; + } + let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); + assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6)); + let out = s.project.comp(ItemId(r["output"].as_u64().unwrap())).unwrap(); + assert_eq!((out.width, out.height), (128, 64)); + let fx = out.layers[0].props.sub("effects").unwrap().groups().next().unwrap().clone(); + assert_eq!(fx.match_id, "ec.vr.converter"); + // Every face camera looks a different way. + let mut dirs = vec![]; + for f in &faces { + let c = s.project.comp(ItemId(*f)).unwrap(); + let cam = c.layers.iter().find(|l| l.is_camera()).unwrap(); + dirs.push(format!("{:?}", cam.props.prop("transform/orientation").unwrap().value)); + } + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), 6, "{dirs:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(ItemId(faces[0])).is_none()); + // Extract Cubemap of a 2:1 comp. + let eq = s.execute("comp.new", json!({"name": "Equi", "width": 128, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute("comp.vr.extractCubemap", json!({"comp": eq})).unwrap(); + let c = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); + assert_eq!((c.width, c.height), (96, 64)); +} + +/// The equirectangular output sees each wall of a box around the rig in the right direction. +#[test] +fn vr_environment_faces_look_the_right_way() { + // One wall at a time (centre 100,100,0; AE's −Y is up), and where the equirectangular output + // must show it: front +Z, back −Z, right +X, left −X, up, down. + let walls = [ + ("front", [100.0, 100.0, 150.0], [0.0, 0.0], (0.5, 0.5)), + ("back", [100.0, 100.0, -150.0], [0.0, 180.0], (0.01, 0.5)), + ("right", [250.0, 100.0, 0.0], [0.0, 90.0], (0.75, 0.5)), + ("left", [-50.0, 100.0, 0.0], [0.0, 90.0], (0.25, 0.5)), + ("up", [100.0, -50.0, 0.0], [90.0, 0.0], (0.5, 0.03)), + ("down", [100.0, 250.0, 0.0], [90.0, 0.0], (0.5, 0.97)), + ]; + for (name, pos, rot, (fx, fy)) in walls { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Room", "width": 200, "height": 200, "duration": 1, "frameRate": 10})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/rotationX", "value": rot[0]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/rotationY", "value": rot[1]})).unwrap(); + let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap(); + let img = s.render(ItemId(r["output"].as_u64().unwrap()), Tick::ZERO, RenderOpts::default()); + let (w, h) = (img.width as f64, img.height as f64); + let alpha = + |x: f64, y: f64| img.data[((y * h) as usize).min(img.height as usize - 1) * img.width as usize + ((x * w) as usize).min(img.width as usize - 1)][3]; + assert!(alpha(fx, fy) > 0.5, "{name}: the wall shows in its direction"); + // The opposite direction is empty. + let (ox, oy) = if fy != 0.5 { (fx, 1.0 - fy) } else { ((fx + 0.5) % 1.0, fy) }; + assert!(alpha(ox, oy) < 0.5, "{name}: nothing in the opposite direction"); + } +} + +// ---------------------------------------------------------------- keyframes + +#[test] +fn convert_audio_to_keyframes_makes_the_amplitude_null() { + assert_eq!(crate::commands::amplitudes_for_tests(&[0.5, -0.25, -0.5, 0.25]), (50.0, 25.0, 37.5)); + let mut s = session(); + let undo = s.history.undo.len(); + let r = s.execute("keys.audioToKeyframes", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, r["layer"].as_u64().unwrap()); + assert_eq!(l.name, "Audio Amplitude"); + let fx: Vec<_> = l.props.sub("effects").unwrap().groups().map(|g| (g.name.clone(), g.get("slider").unwrap().keys.len())).collect(); + assert_eq!(fx, [("Left Channel".to_string(), 20), ("Right Channel".to_string(), 20), ("Both Channels".to_string(), 20)]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layers.is_empty()); +} + +#[test] +fn keyframe_labels_and_label_groups() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + for t in [0.0, 0.5, 1.0] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": 50})).unwrap(); + } + let uid = layer(&s, a).props.prop("transform/opacity").unwrap().uid; + let k = |t: f64| KeyRef { layer: LayerId(a), prop: uid, time: Tick::from_seconds_f64(t) }; + s.state.selected_keys = vec![k(0.0), k(1.0)]; + // Edit ▸ Label colours the selected keyframes. + s.execute("edit.label", json!({"label": "Blue"})).unwrap(); + let labels: Vec = layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.label).collect(); + let blue = effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Blue).unwrap() as u8; + assert_eq!(labels, [blue, 0, blue]); + assert_eq!(layer(&s, a).label, effectcraft_color::Label::Red, "the layer keeps its label"); + // Serde: labels round-trip; unlabeled keys don't write the field. + let json = s.project.to_json(); + assert!(json.contains(&format!("\"label\": {blue}")) || json.contains(&format!("\"label\":{blue}"))); + let back = effectcraft_project::Project::from_json(&json).unwrap(); + assert_eq!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[2].label, blue); + // Moving a labelled key's value keeps the label. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 80})).unwrap(); + assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[2].label, blue); + // Select Keyframe Label Group. + s.state.selected_keys = vec![k(0.0)]; + let r = s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap(); + assert_eq!(r["keys"], 2); + s.state.selected_keys = vec![k(0.5)]; + s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap(); + assert_eq!(s.state.selected_keys, vec![k(0.5)]); + s.state.selected_keys = vec![k(0.0)]; + let r = s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "props": [12345]})).unwrap(); + assert_eq!(r["keys"], 0, "only the given (visible) properties"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().all(|k| k.label == 0)); +} + +#[test] +fn rpf_camera_import_reads_json_and_csv_camera_data() { + let mut s = session(); + let json_path = tmp("cam.json"); + std::fs::write( + &json_path, + r#"{"frameRate": 10, "frames": [{"frame": 0, "position": [0, 0, -500], "rotation": [0, 0, 0], "fov": 90}, {"frame": 10, "position": [100, 0, -500], "orientation": [0, 30, 0], "zoom": 400}]}"#, + ) + .unwrap(); + let undo = s.history.undo.len(); + let r = s.execute("keys.rpfCameraImport", json!({"path": json_path.to_string_lossy()})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let cam = layer(&s, r["layer"].as_u64().unwrap()); + assert!(cam.is_camera()); + let pos = &cam.props.prop("transform/position").unwrap().keys; + assert_eq!(pos.len(), 2); + assert_eq!(pos[1].value, KV::Vec3([100.0, 0.0, -500.0])); + assert!((pos[1].time.seconds() - 1.0).abs() < 1e-9); + let zoom = &cam.props.prop("cameraOptions/zoom").unwrap().keys; + assert!((zoom[0].value.as_f64() - 32.0).abs() < 1e-6, "90° across 64 px = zoom 32: {:?}", zoom[0].value); + let csv = tmp("cam.csv"); + std::fs::write(&csv, "frame,px,py,pz,rx,ry,rz,zoom\n0,1,2,3,0,0,0,100\n5,4,5,6,10,20,30,200\n").unwrap(); + let r = s.execute("keys.rpfCameraImport", json!({"path": csv.to_string_lossy()})).unwrap(); + let cam = layer(&s, r["layer"].as_u64().unwrap()); + let o = &cam.props.prop("transform/orientation").unwrap().keys; + assert_eq!(o[1].value, KV::Vec3([10.0, 20.0, 30.0])); + assert!(s.execute("keys.rpfCameraImport", json!({"path": "/x/scene.rpf"})).unwrap_err().to_string().contains("JSON or CSV")); +} + +// ---------------------------------------------------------------- menus + +#[test] +fn dynamic_menus_and_window_viewer_labels() { + let mut s = session(); + s.prefs.push_recent("/p/One.ecproj"); + let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default()); + assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent")); + assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len()); + let a = solid(&mut s, "#ff0000"); + s.state.selected_layers = vec![LayerId(a)]; + let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None }; + assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main"); + assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main"); + assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: ")); + assert_eq!(crate::menus::entry_label(&s, &entry("window.unavailablePanel", "footage")), "Footage: (none)"); + // Other open comps are listed as viewers. + let other = s.execute("comp.new", json!({"name": "Second"})).unwrap()["comp"].as_u64().unwrap(); + let (e, _) = crate::menus::dynamic(&s, "openViewers", &Default::default()); + assert_eq!(e.iter().map(|e| e.label.as_str()).collect::>(), ["Composition: Main"]); + assert_eq!(e[0].command, "comp.open"); + let _ = other; + // Every tree entry of the new menus resolves. + for (path, e) in crate::menus::entries() { + if ["History", "VR", "Recent Animation Presets", "Import Recent Footage"].iter().any(|p| path.contains(&p.to_string())) { + assert!(crate::commands::find(&e.command).is_some()); + } + } + let labels: Vec = crate::menus::entries().iter().map(|(_, e)| e.label.clone()).collect(); + for l in [ + "Save a Copy As XML...", + "With Layered Comp", + "Create VR Environment...", + "Extract Cubemap...", + "System Compatibility Report...", + "Enable Logging", + "Reveal Logging File", + ] { + assert!(labels.iter().any(|x| x == l), "{l}"); + } +} diff --git a/crates/engine/src/xml_project.rs b/crates/engine/src/xml_project.rs new file mode 100644 index 000000000..ae899d460 --- /dev/null +++ b/crates/engine/src/xml_project.rs @@ -0,0 +1,265 @@ +//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of +//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so +//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again): +//! +//! ```xml +//! +//! +//! +//! 3 +//! … +//! +//! +//! ``` +//! +//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`. + +use serde_json::{Map, Value}; + +const ROOT: &str = "EffectCraftProject"; + +/// Whether `text` looks like an XML project. +pub fn is_xml(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + t.starts_with(" bool { + let p = path.to_ascii_lowercase(); + p.ends_with(".ecprojx") || p.ends_with(".xml") +} + +fn esc(s: &str, out: &mut String) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\r' => out.push_str(" "), + c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)), + c => out.push(c), + } + } +} + +fn write(v: &Value, depth: usize, out: &mut String) { + let pad = " ".repeat(depth); + out.push_str(&pad); + match v { + Value::Null => out.push_str(""), + Value::Bool(b) => out.push_str(&format!("{b}")), + Value::Number(n) => out.push_str(&format!("{n}")), + Value::String(s) => { + out.push_str(""); + esc(s, out); + out.push_str(""); + } + Value::Array(a) if a.is_empty() => out.push_str(""), + Value::Array(a) => { + out.push_str("\n"); + for x in a { + write(x, depth + 1, out); + } + out.push_str(&pad); + out.push_str(""); + } + Value::Object(m) if m.is_empty() => out.push_str(""), + Value::Object(m) => { + out.push_str("\n"); + for (k, x) in m { + out.push_str(&" ".repeat(depth + 1)); + out.push_str("\n"); + write(x, depth + 2, out); + out.push_str(&" ".repeat(depth + 1)); + out.push_str("\n"); + } + out.push_str(&pad); + out.push_str(""); + } + } + out.push('\n'); +} + +/// The project JSON as an XML document. +pub fn to_xml(json: &str) -> Result { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let mut out = format!("\n<{ROOT} encoding=\"json\" version=\"1\">\n"); + write(&v, 1, &mut out); + out.push_str(&format!("\n")); + Ok(out) +} + +struct P<'a> { + s: &'a str, + i: usize, +} + +impl<'a> P<'a> { + fn err(&self, m: &str) -> Result { + Err(format!("XML project: {m} at byte {}", self.i)) + } + fn rest(&self) -> &'a str { + &self.s[self.i..] + } + /// Skip whitespace, comments and processing instructions. + fn skip(&mut self) { + loop { + let t = self.rest(); + let trimmed = t.trim_start(); + self.i += t.len() - trimmed.len(); + if trimmed.starts_with("").map_or(trimmed.len(), |e| e + 2); + } else if trimmed.starts_with("").map_or(trimmed.len(), |e| e + 3); + } else { + return; + } + } + } + /// An opening tag: (name, attribute `n`, self-closing). + fn open(&mut self) -> Result<(String, Option, bool), String> { + self.skip(); + if !self.rest().starts_with('<') || self.rest().starts_with("').ok_or("XML project: unterminated tag")?; + let inner = &self.rest()[1..end]; + self.i += end + 1; + let (inner, closed) = match inner.strip_suffix('/') { + Some(x) => (x, true), + None => (inner, false), + }; + let mut parts = inner.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or_default().to_string(); + let attrs = parts.next().unwrap_or(""); + let n = attrs.find("n=\"").map(|a| { + let v = &attrs[a + 3..]; + unesc(&v[..v.find('"').unwrap_or(v.len())]) + }); + Ok((name, n, closed)) + } + fn close(&mut self, name: &str) -> Result<(), String> { + self.skip(); + let want = format!(""); + if self.rest().starts_with(&want) { + self.i += want.len(); + Ok(()) + } else { + self.err(&format!("expected {want}")) + } + } + fn text(&mut self) -> String { + let end = self.rest().find('<').unwrap_or(self.rest().len()); + let t = unesc(&self.rest()[..end]); + self.i += end; + t + } + fn value(&mut self) -> Result { + let (name, _, closed) = self.open()?; + let v = match (name.as_str(), closed) { + ("null", _) => Value::Null, + ("arr", true) => Value::Array(vec![]), + ("obj", true) => Value::Object(Map::new()), + ("str", true) => Value::String(String::new()), + ("bool", false) => Value::Bool(self.text().trim() == "true"), + ("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?, + ("str", false) => Value::String(self.text()), + ("arr", false) => { + let mut a = vec![]; + loop { + self.skip(); + if self.rest().starts_with(" { + let mut m = Map::new(); + loop { + self.skip(); + if self.rest().starts_with(""); + } + let v = self.value()?; + self.close("m")?; + m.insert(key.unwrap_or_default(), v); + } + Value::Object(m) + } + (other, _) => return self.err(&format!("unexpected <{other}>")), + }; + if !closed && name != "null" { + self.close(&name)?; + } + Ok(v) + } +} + +fn unesc(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(a) = rest.find('&') { + out.push_str(&rest[..a]); + rest = &rest[a..]; + let Some(e) = rest.find(';') else { break }; + let ent = &rest[1..e]; + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)), + n if n.starts_with('#') => out.extend(n[1..].parse::().ok().and_then(char::from_u32)), + other => { + out.push('&'); + out.push_str(other); + out.push(';'); + } + } + rest = &rest[e + 1..]; + } + out.push_str(rest); + out +} + +/// The project JSON of an XML project document. +pub fn from_xml(text: &str) -> Result { + let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 }; + let (root, _, _) = p.open()?; + if root != ROOT { + return Err(format!("not an EffectCraft XML project (root <{root}>)")); + } + let v = p.value()?; + p.close(ROOT)?; + serde_json::to_string(&v).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_round_trips_through_xml() { + let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x")); + let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap(); + assert_eq!(back, j); + assert!(from_xml("").is_err()); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index a2af6a4f4..30da500a8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -69,6 +69,8 @@ pub struct Job<'a> { pub path: &'a str, /// Hand finished files to this instead of writing them to disk (web downloads, tests). pub sink: Option<&'a Sink>, + /// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`). + pub nested_switches: bool, } /// Progress after a batch of frames. @@ -182,6 +184,8 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { view: None, backend: effectcraft_render::Backend::Auto, roi: None, + nested_switches: job.nested_switches, + draft_shadows: true, }; let mut r = Renderer::new(job.project, job.footage, opts); r.expr = job.expr; diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 1ade594f8..f8bef9f25 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -55,7 +55,7 @@ fn settings() -> RenderSettings { fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report { let s = settings(); let path = path.to_string_lossy().to_string(); - let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None }; + let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true }; let mut last = Progress::default(); let mut calls = 0; let r = export(&job, &mut |pr| { @@ -167,7 +167,8 @@ fn h264_odd_size_is_cropped_even() { let comp = p.comp(cid).expect("comp"); assert_eq!(s.output_size(comp).0 % 2, 1); let path = d.join("odd.mp4").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = + Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true }; let r = export(&job, &mut |_| true).expect("export"); assert_eq!((r.width % 2, r.height % 2), (0, 0)); } @@ -287,7 +288,8 @@ fn cancel_stops_early() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = settings(); let path = d.join("c_[#####].png").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = + Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true }; let r = export(&job, &mut |_| false); assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index aa94b5589..20a76cb32 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -40,6 +40,7 @@ impl Exporter for FileExporter { output: &job.item.output, path: job.path, sink: self.sink.as_deref(), + nested_switches: job.nested_switches, }; match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }), diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 731e167ca..7a3c6c890 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -84,6 +84,14 @@ pub struct Keyframe { pub spatial_continuous: bool, #[serde(default)] pub roving: bool, + /// Keyframe colour label (Edit ▸ Label on selected keyframes): 0 = None, 1–16 = the 16 + /// label colours (Red … Dark Green). + #[serde(default, skip_serializing_if = "is_zero")] + pub label: u8, +} + +fn is_zero(v: &u8) -> bool { + *v == 0 } fn yes() -> bool { @@ -106,6 +114,7 @@ impl Keyframe { spatial_auto: true, spatial_continuous: false, roving: false, + label: 0, } } pub fn hold(mut self) -> Keyframe { @@ -512,6 +521,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_in = old.spatial_in; k.spatial_out = old.spatial_out; k.spatial_auto = old.spatial_auto; + k.label = old.label; keys[i] = k; i } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index f604a69af..b34463a06 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -131,6 +131,11 @@ struct Inner { hits: AtomicU64, misses: AtomicU64, prefetched: AtomicU64, + /// Settings ▸ Disk ▸ Conformed Audio Folder: decoded audio tracks written once per file and + /// rate, read back instead of decoding again (`None` = off). + conform: Mutex>, + /// Conformed files being written right now. + conforming: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -193,6 +198,8 @@ impl MediaPool { hits: AtomicU64::new(0), misses: AtomicU64::new(0), prefetched: AtomicU64::new(0), + conform: Mutex::default(), + conforming: Mutex::default(), }), } } @@ -257,14 +264,108 @@ impl MediaPool { Inner::frame_at(&self.inner, footage, t, true) } + /// Settings ▸ Disk ▸ Conformed Audio Folder: write each footage file's decoded audio there + /// once per sample rate (raw interleaved stereo `f32`) and read it back instead of decoding + /// again. `None` turns it off. + pub fn set_conform_folder(&self, folder: Option) { + *lock(&self.inner.conform) = folder; + } + + /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and + /// modification time, so an edited file conforms again). + pub fn conformed_path(&self, path: &str, rate: u32) -> Option { + use std::hash::{Hash, Hasher}; + let folder = lock(&self.inner.conform).clone()?; + let meta = std::fs::metadata(path).ok()?; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (path, meta.len(), meta.modified().ok()).hash(&mut h); + Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) + } + + /// Samples from a conformed file: `Some` when it exists and covers the request. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn read_conformed(file: &std::path::Path, s0: usize, frames: usize) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(file).ok()?; + let len = f.metadata().ok()?.len() as usize / 8; + let mut out = vec![0.0f32; frames * 2]; + if s0 >= len { + return Some(out); + } + let n = frames.min(len - s0); + 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)) { + *o = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); + } + Some(out) + } + + /// Conform `footage`'s audio at `rate` into `file` on a worker thread (once). + #[cfg(not(target_arch = "wasm32"))] + fn conform(&self, footage: &Footage, file: std::path::PathBuf, rate: u32) { + if !lock(&self.inner.conforming).insert(file.clone()) { + return; + } + let (pool, f) = (self.clone(), footage.clone()); + let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || { + let frames = f.duration.to_units_floor(rate as i64).max(0) as usize; + let mut bytes = Vec::with_capacity(frames * 8); + let mut at = 0usize; + while at < frames { + let n = (frames - at).min(1 << 16); + let t = Tick(at as i64 * TICKS_PER_SECOND / rate as i64); + for v in pool.decode_audio(&f, t, n, rate) { + bytes.extend_from_slice(&v.to_le_bytes()); + } + at += n; + } + let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) && { + let tmp = file.with_extension("tmp"); + std::fs::write(&tmp, &bytes).is_ok() && std::fs::rename(&tmp, &file).is_ok() + }; + if !ok { + log::warn!("media: could not write conformed audio {}", file.display()); + } + lock(&pool.inner.conforming).remove(&file); + }); + if spawned.is_err() { + lock(&self.inner.conforming).clear(); + } + } + /// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond /// the first two are dropped. Silence where there is no audio (or before the media starts). + /// With a conformed audio folder, reads come from the conformed file once it is written. pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { - let mut out = vec![0.0; frames * 2]; if !footage.has_audio || footage.missing || rate == 0 || frames == 0 { - return out; + return vec![0.0; frames * 2]; } + #[cfg(not(target_arch = "wasm32"))] + if let Some(file) = self.conformed_path(&footage.path, rate) { + let s0 = start.to_units_floor(rate as i64); + if file.exists() && !lock(&self.inner.conforming).contains(&file) { + let skip = (-s0).max(0) as usize; + if skip >= frames { + return vec![0.0; frames * 2]; + } + if let Some(v) = Self::read_conformed(&file, s0.max(0) as usize, frames - skip) { + let mut out = vec![0.0; skip * 2]; + out.extend(v); + return out; + } + } else { + self.conform(footage, file, rate); + } + } + self.decode_audio(footage, start, frames, rate) + } + + /// [`MediaPool::audio_samples`] straight from the decoder. + fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + let mut out = vec![0.0; frames * 2]; let path: Arc = footage.path.as_str().into(); let Some(src) = self.inner.source(&path) else { return out }; let s0 = start.to_units_floor(rate as i64); @@ -486,6 +587,9 @@ impl Inner { } impl FootageSource for MediaPool { + fn set_conform_folder(&self, folder: Option) { + MediaPool::set_conform_folder(self, folder); + } fn model(&self, _item: ItemId, footage: &Footage) -> Option> { if footage.missing || footage.kind != FootageKind::Model { return None; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index db1ace7d9..bff830a7e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,6 +53,9 @@ pub trait FootageSource: Send + Sync { /// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without /// a cache ignore it. fn set_cache_budget(&self, _bytes: usize) {} + /// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads + /// (`None` = off); sources that don't decode audio ignore it. + fn set_conform_folder(&self, _folder: Option) {} /// The decoded-frame cache budget in bytes, if the source has a cache. fn cache_budget(&self) -> Option { None @@ -242,11 +245,34 @@ pub struct RenderOpts { /// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers /// just this rectangle, and only its pixels are composited. pub roi: Option<[f64; 4]>, + /// Switches Affect Nested Comps (Settings ▸ General): a precomp layer's Quality and Motion + /// Blur switches also limit the layers of the nested comp (Draft/Wireframe or motion blur off + /// propagate down; they never raise a nested layer's own setting). + pub nested_switches: bool, + /// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows. + pub draft_shadows: bool, +} + +impl RenderOpts { + /// Whether 3D layers cast shadows in this render. + pub fn shadows(&self) -> bool { + !self.draft || self.draft_shadows + } } impl Default for RenderOpts { fn default() -> Self { - RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu } + RenderOpts { + scale: 1.0, + motion_blur: true, + guides: false, + draft: false, + view: None, + roi: None, + backend: Backend::Cpu, + nested_switches: true, + draft_shadows: true, + } } } @@ -272,6 +298,9 @@ pub struct Renderer<'a> { opacity_mul: f32, /// Collapsed precomp: nested 3D layers join the parent's 3D space. pub(crate) collapse3d: Option>, + /// Switches inherited from the precomp layers this comp is rendered through (see + /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. + pub(crate) inherited: Option<(Quality, bool)>, } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -315,9 +344,39 @@ impl<'a> Renderer<'a> { outer: None, opacity_mul: 1.0, collapse3d: None, + inherited: None, } } + /// A layer's Quality switch, limited by the precomp layers above it when + /// [`RenderOpts::nested_switches`] is on. + pub fn quality(&self, layer: &Layer) -> Quality { + let rank = |q: Quality| match q { + Quality::Best => 0, + Quality::Draft => 1, + Quality::Wireframe => 2, + }; + match self.inherited { + Some((q, _)) if rank(q) > rank(layer.switches.quality) => q, + _ => layer.switches.quality, + } + } + + /// A layer's Motion Blur switch, limited by the precomp layers above it. + pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool { + layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb) + } + + /// The renderer for the nested comp of precomp layer `layer`: its switches pass down when + /// [`RenderOpts::nested_switches`] is on. + fn nested_through(&self, layer: &Layer) -> Renderer<'a> { + let mut sub = self.nested(); + if self.opts.nested_switches { + sub.inherited = Some((self.quality(layer), self.layer_motion_blur(layer))); + } + sub + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } @@ -671,7 +730,7 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = self.nested(); + let sub = self.nested_through(layer); let lt = ctx.source_time(layer); // Frame blending between the nested comp's frames (time-stretched/remapped). let mode = frame_blend_mode(ctx, layer); @@ -925,7 +984,7 @@ impl<'a> Renderer<'a> { } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { - if layer.switches.quality == Quality::Draft { + if self.quality(layer) == Quality::Draft { // Draft quality: no interpolation (nearest neighbour), as After Effects' Draft. effectcraft_raster::Sampling::Nearest } else if self.opts.draft { @@ -970,7 +1029,7 @@ impl<'a> Renderer<'a> { /// Motion-blur sub-samples for a layer (1 = no motion blur). fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize { - if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { + if self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer) { if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } } else { 1 @@ -1056,7 +1115,7 @@ impl<'a> Renderer<'a> { /// Draw a 2D layer into `canvas`; returns the region it may have changed. fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; - if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment { + if self.quality(layer) == Quality::Wireframe && !layer.switches.adjustment { self.draw_wireframe(ctx, layer, canvas); return Region::Full; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 14251745d..a28e3e3f8 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -840,7 +840,14 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32) { add_environment(&mut b, w, h, d, k, rot); } - super::raster::shadow_maps(&mut b.s, r.opts.draft); + if r.opts.shadows() { + super::raster::shadow_maps(&mut b.s, r.opts.draft); + } else { + // Settings ▸ 3D ▸ Realtime Shadows in Draft off: draft renders skip shadows. + for l in &mut b.s.lights { + l.shadow = -1; + } + } } // Camera matrices. let s = r.opts.scale; diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 2647fadd6..747b04f97 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -162,7 +162,7 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { - if r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { + if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } } else { 1 @@ -335,7 +335,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut let mut items: Vec = run .par_iter() .enumerate() - .filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe) + .filter(|(_, l)| ctx.opacity(l) > 0.0 && r.quality(l) != effectcraft_project::Quality::Wireframe) .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; @@ -366,9 +366,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .collect(); } items.sort_by_key(|i| i.order); - let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| r.opts.shadows() && i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); - for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + for l in run.iter().filter(|l| r.quality(l) == effectcraft_project::Quality::Wireframe) { r.draw_layer(ctx, l, canvas, false); } } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index c4f256f4b..fec9d9e39 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -204,7 +204,7 @@ File.prototype = { return true; }, rename: function () { throw __err("File.rename is not supported"); }, - execute: function () { throw __err("scripts can't execute files"); }, + execute: function () { return JSON.parse(__file("execute", this.__path)); }, openDlg: function () { return null; }, saveDlg: function () { return null; }, }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 61b495950..c494a6bf3 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -294,6 +294,11 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 2 { arg_string(args, 2, ctx)? } else { String::new() }; let r: Result = with_active(|a| { + // File.execute(): opened with its default application through `file.executeFile` + // (Allow Scripts to Write Files gate, Warn User When Executing Files confirmation). + if op == "execute" { + return exec(&mut a.session, "file.executeFile", json!({"path": path})).map(|v| json!(v.get("opened").is_some())); + } let s = &a.session; match op.as_str() { "access" => Ok(json!(check_access(s, &path, false)?.exists())), diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c3a60f5d0..009f25225 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -48,6 +48,8 @@ pub struct FaceInfo { pub data: FaceData, /// "bundled", "system", … pub origin: &'static str, + /// The family name in the font's own (non-English) language, when it has one. + pub native_family: Option, } /// Something that can list fonts (system folders, a web font picker, a project's font folder…). @@ -121,7 +123,7 @@ impl FontSource for DirectorySource { } fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { - FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin } + FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin, native_family: n.native_family } } /// A loaded (or loadable) face. @@ -300,6 +302,12 @@ pub fn all_faces() -> Vec> { db().read().unwrap_or_else(|e| e.into_inner()).faces.clone() } +/// Family names in the fonts' own languages: English family → native family (only families +/// that have one). +pub fn native_families() -> std::collections::BTreeMap { + all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect() +} + /// Families with their style names, sorted by family; styles in weight order. pub fn families() -> Vec<(String, Vec)> { let mut m: std::collections::BTreeMap> = Default::default(); diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index bb00d515f..72e345064 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -13,6 +13,9 @@ pub struct FaceNames { pub full_name: String, pub weight: u16, pub italic: bool, + /// The family name in another language than English (CJK, Arabic… fonts), when the font + /// has one (Settings ▸ Type ▸ Show Font Names in English off shows it). + pub native_family: Option, } fn be16(b: &[u8], o: usize) -> Option { @@ -75,6 +78,7 @@ fn read_face(r: &mut R, off: u64, index: u32) -> Option(r: &mut R, off: u64, index: u32) -> Option Option { + let count = be16(b, 2)? as usize; + let storage = be16(b, 4)? as usize; + for i in 0..count { + let r = 6 + i * 12; + let (plat, enc, lang, nid, len, off) = + (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); + if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 { + continue; + } + let raw = b.get(storage + off..storage + off + len)?; + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let s = String::from_utf16_lossy(&u); + if !s.trim().is_empty() { + return Some(s); + } + } + None } /// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman. diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 6659c77d3..483b3e0fc 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -47,11 +47,13 @@ pub struct AudioOutput { pub device: String, pub left: u32, pub right: u32, + /// Preview Sample Rate: devices that support it are opened at this rate. + pub rate: u32, } impl AudioOutput { pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput { - AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right } + AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right, rate: p.audio.preview_sample_rate } } } @@ -166,6 +168,9 @@ pub struct AudioPlayback { impl AudioPlayback { /// Start mixing `comp` over `span` into `device`. + /// `mix_rate`: Settings ▸ Audio ▸ Preview Sample Rate, the rate the comp's audio is mixed + /// at (resampled to the device's rate when they differ; 0 = the device's rate). + #[allow(clippy::too_many_arguments)] pub fn start( mut device: Box, src: RenderSource, @@ -174,8 +179,10 @@ impl AudioPlayback { wa: Tick, wb: Tick, looping: bool, + mix_rate: u32, ) -> Result { let rate = device.sample_rate().max(8000); + let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) }; let span = PlaySpan::new(start, wa, wb, looping, rate); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); @@ -183,7 +190,7 @@ impl AudioPlayback { let mut cursor = span.start; let prime = (rate as usize / 20).div_ceil(BLOCK); for _ in 0..prime { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate); + cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); } let thread = { let (feed, stop) = (feed.clone(), stop.clone()); @@ -194,7 +201,7 @@ impl AudioPlayback { let target = rate as usize / 5; while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate); + cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -232,7 +239,7 @@ impl Drop for AudioPlayback { /// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at /// the work-area end). -fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32) -> i64 { +fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 { let mut cursor = cursor; if cursor >= span.wb { if !span.looping { @@ -242,11 +249,39 @@ fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpa cursor = span.wa; } let n = ((span.wb - cursor) as usize).min(BLOCK); - let buf = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, sample_time(cursor, rate), n, rate); + let t = sample_time(cursor, rate); + let buf = if mix == rate { + effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, n, rate) + } else { + let m = (n as u64 * mix as u64).div_ceil(rate as u64) as usize + 1; + let b = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, m, mix); + resample_stereo(&b, mix, rate, n) + }; feed.push(&buf); cursor + n as i64 } +/// Linear resampling of interleaved stereo from `from` Hz to `n` frames at `to` Hz. +pub fn resample_stereo(src: &[f32], from: u32, to: u32, n: usize) -> Vec { + let frames = src.len() / 2; + let mut out = Vec::with_capacity(n * 2); + if frames == 0 { + out.resize(n * 2, 0.0); + return out; + } + let step = from as f64 / to as f64; + for i in 0..n { + let x = i as f64 * step; + let a = (x.floor() as usize).min(frames - 1); + let b = (a + 1).min(frames - 1); + let f = (x - a as f64) as f32; + for c in 0..2 { + out.push(src[a * 2 + c] * (1.0 - f) + src[b * 2 + c] * f); + } + } + out +} + /// VU meter state for the Audio panel: smoothed level with a falling decay and peak hold, in /// dBFS per channel. #[derive(Clone, Copy, Debug)] diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index cd052c3e2..e2373c9a0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -776,6 +776,10 @@ pub fn draw_group_chrome( } } else { let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); + if t.gradients { + // Settings ▸ Appearance ▸ Use Gradients. + crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg); + } let mut x = strip.min.x + 12.0; let text_y = strip.min.y + 16.0; for (i, p) in g.panels.iter().enumerate() { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 449009189..3b222570a 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -29,6 +29,30 @@ impl EffectcraftApp { out } + /// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline + /// tabs carry their comp's label colour, Effect Controls and Properties the layer's. + fn label_tab_marks(&self, ui: &egui::Ui) { + if !self.session.prefs.appearance.use_label_color_for_tabs { + return; + } + let Some(cid) = self.session.active_comp_id() else { return }; + let comp_label = self.session.project.item(cid).map(|i| i.label); + let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label); + for (p, label) in [ + (PanelKind::Composition, comp_label), + (PanelKind::Timeline, comp_label), + (PanelKind::EffectControls, layer_label), + (PanelKind::Properties, layer_label), + ] { + let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue }; + if label == effectcraft_engine::color::Label::None { + continue; + } + let r = Rect::from_min_size(pos2(e.rect[0] + 2.0, e.rect[1] + 9.0), vec2(4.0, (e.rect[3] - 16.0).max(6.0))); + ui.painter().rect_filled(r, 1.0, self.tokens.label(label)); + } + } + /// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation. pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool { let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() }; @@ -98,6 +122,7 @@ impl EffectcraftApp { for g in &groups { actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); } + self.label_tab_marks(ui); if maximized.is_none() { self.ui.dock = dock; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0976c47a0..e4ec95bb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod icons; pub mod menus; pub mod native_menu; pub mod panels; +pub mod prefs_live; pub mod state; pub mod theme; pub mod widgets; @@ -250,7 +251,7 @@ impl EffectcraftApp { ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); self.ui.cache_when_idle = p.previews.cache_frames_when_idle; self.ui.viewer.fast_preview = p.previews.fast_previews; - self.frames.set_budget(p.preview_cache_bytes()); + self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview); } /// Change settings from the UI (applied next frame and saved). @@ -434,6 +435,8 @@ impl EffectcraftApp { view: self.session.view_camera(comp), roi, backend: effectcraft_engine::render::Backend::Auto, + nested_switches: self.session.prefs.general.switches_affect_nested_comps, + draft_shadows: self.session.prefs.three_d.realtime_shadows, } } @@ -486,7 +489,8 @@ impl EffectcraftApp { } let out = audio::AudioOutput::from_prefs(&self.session.prefs); let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) { + let mix = self.session.prefs.audio.preview_sample_rate; + match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop, mix) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } @@ -758,6 +762,7 @@ impl EffectcraftApp { self.apply_prefs(&ctx); self.handle_events(&ctx); self.tick_autosave(&ctx); + prefs_live::frame(self, &ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e48bb5145..89a2353b6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -746,6 +746,37 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.show_panel(PanelKind::Layer); Value::Null } + "app.confirmExecute" => { + // Settings ▸ Scripting ▸ Warn User When Executing Files. + let path = p.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); + crate::panels::forms::form( + app, + "A script wants to open a file", + "file.executeFile", + json!({"path": path, "confirmed": true}), + vec![crate::panels::forms::Field::text("path", "Open", &path)], + ); + Value::Null + } + "app.showReport" => { + let title = p.get("title").and_then(Value::as_str).unwrap_or("Report").to_string(); + let text = p.get("text").and_then(Value::as_str).unwrap_or_default().to_string(); + crate::panels::dialogs::info(app, &title, &text); + Value::Null + } + "window.assignWorkspaceShortcut" => { + let mut q = p.clone(); + q["workspace"] = json!(app.ui.workspace); + return run_engine(app, ctx, "window.assignWorkspaceShortcut", q); + } + "file.interpretFootage" => { + // Import ▸ Interpret Unlabeled Alpha As: Ask User. + if let Some(i) = p.get("item").and_then(Value::as_u64) { + app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)]; + } + crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({})); + Value::Null + } "effect.manage" | "anim.browsePresets" => { app.show_panel(PanelKind::EffectsPresets); Value::Null @@ -786,6 +817,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), "file.replaceFootage" => ("path", Ask::Import), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), + "file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")), + "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), _ => return None, }; if params.get(key).is_some() @@ -862,6 +895,15 @@ pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String { match e.command.as_str() { "edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()), "edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()), + // Window ▸ Layer: the layer open in the Layer panel. + "window.panel" if e.params.get("panel").and_then(Value::as_str) == Some("layer") => { + let name = app + .ui + .layer_panel + .and_then(|id| app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(id)))) + .map(|l| l.name.clone()); + format!("Layer: {}", name.unwrap_or_else(|| "(none)".into())) + } _ => effectcraft_engine::menus::entry_label(&app.session, e), } } @@ -1126,29 +1168,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c MenuNode::Separator => { ui.separator(); } - MenuNode::Submenu { label, children } if label == "Open Recent" => { - // File ▸ Open Recent: the recent projects (Settings), then the static entries. - ui.menu_button((gutter(false), label.as_str()), |ui| { - ui.set_min_width(320.0); - let recent = app.session.prefs.recent_projects.clone(); - if recent.is_empty() { - ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects"))); + MenuNode::Dynamic { name } => { + // Recent projects / footage / presets, undo history, shortcut slots, viewers. + let ws = app.ui.workspace.clone(); + let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws)); + if entries.is_empty() + && let Some(e) = empty + { + ui.add_enabled(false, egui::Button::new((gutter(false), e))); + } + for (i, e) in entries.iter().enumerate() { + let tip = match name.as_str() { + "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), + "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(), + "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(), + _ => None, + }; + let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(false), e.label.as_str()))); + let r = match &tip { + Some(t) => r.on_hover_text(t), + None => r, + }; + app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label); + if r.clicked() { + *clicked = Some((e.command.clone(), e.params.clone())); + ui.close(); } - for (i, path) in recent.iter().enumerate() { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path); - app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path); - if r.clicked() { - *clicked = Some(("file.openRecent".into(), json!({"index": i}))); - ui.close(); - } - } - ui.separator(); - menu_nodes(app, ui, children, clicked); - }); + } } MenuNode::Submenu { label, children } => { - ui.menu_button((gutter(false), label.as_str()), |ui| { + let ws = app.ui.workspace.clone(); + let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws)); + ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); // Long submenus (Blending Mode, effect categories) scroll instead of running // off the screen. @@ -1166,6 +1217,11 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c } } +/// Frontend state for dynamic menus (the current workspace). +pub(crate) fn dyn_ctx(workspace: &str) -> effectcraft_engine::menus::DynCtx<'_> { + effectcraft_engine::menus::DynCtx { workspace: Some(workspace) } +} + /// The check-mark column every menu row reserves (like macOS / After Effects menus). fn gutter(checked: bool) -> egui::Atom<'static> { use egui::AtomExt; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index ef5070b36..42c3aacfc 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -134,28 +134,41 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { let id = format!("{path}.{i}"); match n { MenuNode::Separator => out.push(NativeNode::Separator), + MenuNode::Dynamic { name } => { + // Recent projects / footage / presets, undo history, shortcut slots, viewers. + let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace)); + if entries.is_empty() + && let Some(e) = empty + { + out.push(NativeNode::Item(NativeItem { + id: format!("{id}.none"), + label: e.to_string(), + command: String::new(), + params: Value::Null, + shortcut: None, + accelerator: None, + enabled: false, + checked: None, + })); + } + for (k, e) in entries.iter().enumerate() { + out.push(NativeNode::Item(NativeItem { + id: format!("{id}.d{k}"), + label: e.label.clone(), + command: e.command.clone(), + params: e.params.clone(), + shortcut: None, + accelerator: None, + enabled: crate::menus::entry_enabled(app, e), + checked: None, + })); + } + } MenuNode::Submenu { label, children } => { let mut kids = vec![]; - if label == "Open Recent" { - for (k, p) in app.session.prefs.recent_projects.iter().enumerate() { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone()); - kids.push(NativeNode::Item(NativeItem { - id: format!("{id}.r{k}"), - label: name, - command: "file.openRecent".into(), - params: json!({"index": k}), - shortcut: None, - accelerator: None, - enabled: true, - checked: None, - })); - } - if !kids.is_empty() { - kids.push(NativeNode::Separator); - } - } rec(app, children, &id, &mut kids); - out.push(NativeNode::Submenu { label: label.clone(), children: kids }); + let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &crate::menus::dyn_ctx(&app.ui.workspace)); + out.push(NativeNode::Submenu { label: shown, children: kids }); } MenuNode::Item(e) => { let role = match e.command.as_str() { @@ -272,6 +285,11 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.journal.len().hash(&mut h); s.journal.last().map(|j| &j.0).hash(&mut h); s.prefs.recent_projects.hash(&mut h); + (&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h); + s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); + s.state.open_comps.hash(&mut h); + s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); + s.shortcuts().keys.len().hash(&mut h); s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -340,7 +358,10 @@ mod tests { fn commands(n: &[NativeNode], out: &mut Vec<(String, String)>) { for x in n { match x { - NativeNode::Item(i) if i.command != "file.openRecent" => out.push((i.command.clone(), i.params.to_string())), + // Dynamic entries (recent files, History, viewers…) are not tree entries. + NativeNode::Item(i) if !i.id.rsplit('.').next().is_some_and(|l| l.starts_with('d') || l == "none") => { + out.push((i.command.clone(), i.params.to_string())) + } NativeNode::Predefined { command, .. } if !command.is_empty() => out.push((command.clone(), "null".into())), NativeNode::Submenu { children, .. } => commands(children, out), _ => {} @@ -431,7 +452,9 @@ mod tests { app.session.execute("layer.setSwitch", json!({"switch": "lock"})).unwrap(); assert_ne!(state_key(&app), k0); let spec1 = build(&app); - assert_eq!(spec0.structure(), spec1.structure(), "state changes don't rebuild the menu"); + // The new undo step joins Edit ▸ History (a dynamic submenu); nothing else is rebuilt. + let history = |s: &NativeMenu| s.items().into_iter().filter(|i| i.command == "edit.history").count(); + assert_eq!(history(&spec1), history(&spec0) + 1); let lock = spec1.items().into_iter().find(|i| i.command == "layer.setSwitch" && i.params["switch"] == "lock").unwrap().checked; assert_eq!(lock, Some(true)); assert_ne!(undo(&spec0), undo(&spec1)); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs new file mode 100644 index 000000000..1efd6876f --- /dev/null +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -0,0 +1,394 @@ +//! The expression editor of the Timeline, following Settings ▸ Scripting & Expressions: +//! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching +//! and Word Wrap; and the Composition panel's expression error banner (Show Expression Error +//! Banner). + +use effectcraft_engine::prefs::Scripting; +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; + +use crate::theme::Tokens; + +/// Syntax colours. +const KEYWORD: Color32 = Color32::from_rgb(0xc6, 0x8a, 0xe6); +const NUMBER: Color32 = Color32::from_rgb(0xe0, 0xb4, 0x6c); +const STRING: Color32 = Color32::from_rgb(0x9c, 0xd0, 0x7a); +const COMMENT: Color32 = Color32::from_rgb(0x80, 0x88, 0x90); +const API: Color32 = Color32::from_rgb(0x6c, 0xb8, 0xf0); +const BRACKET_BG: Color32 = Color32::from_rgb(0x4a, 0x55, 0x6a); + +const KEYWORDS: &[&str] = &[ + "var", + "let", + "const", + "function", + "return", + "if", + "else", + "for", + "while", + "do", + "break", + "continue", + "new", + "true", + "false", + "null", + "undefined", + "this", + "typeof", + "in", + "of", + "switch", + "case", + "default", + "try", + "catch", + "throw", +]; + +/// Expression language names offered by auto-complete (public Expression Language Reference +/// vocabulary). +pub const COMPLETIONS: &[&str] = &[ + "thisComp", + "thisLayer", + "thisProperty", + "time", + "value", + "index", + "layer", + "effect", + "mask", + "content", + "transform", + "position", + "anchorPoint", + "scale", + "rotation", + "opacity", + "orientation", + "xRotation", + "yRotation", + "zRotation", + "wiggle", + "loopOut", + "loopIn", + "loopOutDuration", + "loopInDuration", + "valueAtTime", + "velocityAtTime", + "speedAtTime", + "linear", + "ease", + "easeIn", + "easeOut", + "clamp", + "random", + "gaussRandom", + "seedRandom", + "noise", + "degreesToRadians", + "radiansToDegrees", + "timeToFrames", + "framesToTime", + "timeToTimecode", + "length", + "lookAt", + "toComp", + "fromComp", + "toWorld", + "fromWorld", + "sourceRectAtTime", + "sourceText", + "numKeys", + "key", + "nearestKey", + "inPoint", + "outPoint", + "startTime", + "width", + "height", + "frameDuration", + "marker", + "Math", + "comp", + "footage", + "posterizeTime", + "add", + "sub", + "mul", + "div", + "normalize", + "cross", + "dot", + "hslToRgb", + "rgbToHsl", + "createPath", + "points", + "inTangents", + "outTangents", + "isClosed", +]; + +/// The bracket matching the one at or just before char index `cursor`: (open, close) char +/// indices. +pub fn matching_bracket(text: &str, cursor: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + let pairs = [('(', ')'), ('[', ']'), ('{', '}')]; + for at in [cursor, cursor.wrapping_sub(1)] { + let Some(&c) = chars.get(at) else { continue }; + if let Some(&(o, cl)) = pairs.iter().find(|(o, _)| *o == c) { + let mut depth = 0; + for (i, &x) in chars.iter().enumerate().skip(at) { + if x == o { + depth += 1; + } else if x == cl { + depth -= 1; + if depth == 0 { + return Some((at, i)); + } + } + } + } + if let Some(&(o, cl)) = pairs.iter().find(|(_, cl)| *cl == c) { + let mut depth = 0; + for i in (0..=at).rev() { + if chars[i] == cl { + depth += 1; + } else if chars[i] == o { + depth -= 1; + if depth == 0 { + return Some((i, at)); + } + } + } + } + } + None +} + +/// The identifier being typed before char index `cursor`, and auto-complete candidates for it. +pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { + let chars: Vec = text.chars().collect(); + let end = cursor.min(chars.len()); + let mut start = end; + while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') { + start -= 1; + } + let word: String = chars[start..end].iter().collect(); + if word.chars().count() < 2 || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return (word, vec![]); + } + let c: Vec<&str> = COMPLETIONS.iter().copied().filter(|w| w.starts_with(word.as_str()) && *w != word).take(6).collect(); + (word, c) +} + +/// Colour runs for JavaScript: (byte start, byte end, colour). +pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { + let b = text.as_bytes(); + let mut out = vec![]; + let mut i = 0; + while i < b.len() { + let c = b[i]; + let start = i; + let col = if c == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + COMMENT + } else if c == b'/' && b.get(i + 1) == Some(&b'*') { + i += 2; + while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(b.len()); + COMMENT + } else if c == b'"' || c == b'\'' || c == b'`' { + i += 1; + while i < b.len() && b[i] != c { + i += if b[i] == b'\\' { 2 } else { 1 }; + } + i = (i + 1).min(b.len()); + STRING + } else if c.is_ascii_digit() { + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') { + i += 1; + } + NUMBER + } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') { + i += 1; + } + let w = &text[start..i]; + if KEYWORDS.contains(&w) { + KEYWORD + } else if COMPLETIONS.contains(&w) { + API + } else { + base + } + } else { + // Advance one whole character. + i += text[i..].chars().next().map_or(1, char::len_utf8); + base + }; + match out.last_mut() { + Some((_, e, c)) if *c == col && *e == start => *e = i, + _ => out.push((start, i, col)), + } + } + out +} + +/// Byte index of char index `ci`. +fn byte_of(text: &str, ci: usize) -> usize { + text.char_indices().nth(ci).map(|(b, _)| b).unwrap_or(text.len()) +} + +/// The layout job of the editor text: syntax colours, the matched brackets' background, wrap. +fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + let runs = if p.syntax_highlighting { highlight(text, base) } else { vec![(0, text.len(), base)] }; + let marks: Vec = brackets.filter(|_| p.bracket_matching).map(|(a, b)| vec![byte_of(text, a), byte_of(text, b)]).unwrap_or_default(); + for (s, e, col) in runs { + // Split runs at the marked brackets. + let mut cuts = vec![s]; + for m in &marks { + if *m >= s && *m < e { + cuts.push(*m); + cuts.push((*m + 1).min(e)); + } + } + cuts.push(e); + cuts.sort_unstable(); + cuts.dedup(); + for w in cuts.windows(2) { + let bg = if marks.contains(&w[0]) { BRACKET_BG } else { Color32::TRANSPARENT }; + job.append(&text[w[0]..w[1]], 0.0, TextFormat { font_id: font.clone(), color: col, background: bg, ..Default::default() }); + } + } + job.wrap.max_width = if p.word_wrap { wrap } else { f32::INFINITY }; + job +} + +/// The editor: returns the response; `buf` is edited in place. +pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response { + let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9; + let font = Tokens::mono(size); + // Line numbers in a gutter on the left. + let gutter = if p.line_numbers { (size * 0.62) * (buf.lines().count().max(1).to_string().len() as f32 + 1.0) + 6.0 } else { 0.0 }; + let er = Rect::from_min_max(pos2(rect.min.x + gutter, rect.min.y), rect.max); + let cursor = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0); + let brackets = cursor.and_then(|c| matching_bracket(buf, c)); + let pc = p.clone(); + let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| { + let job = layout(text.as_str(), font.clone(), color, &pc, brackets, wrap); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); + // Auto-complete: Tab accepts the first candidate (consumed before the editor sees it). + let mut accepted = None; + if p.auto_complete + && child.memory(|m| m.has_focus(id)) + && let Some(c) = cursor + { + let (word, cands) = completions(buf, c); + if let Some(first) = cands.first() + && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) + { + let at = byte_of(buf, c); + buf.insert_str(at, &first[word.len()..]); + accepted = Some(c + first.chars().count() - word.chars().count()); + } + } + let out = egui::TextEdit::multiline(buf) + .id(id) + .font(Tokens::mono(size)) + .desired_width(er.width()) + .desired_rows(rows) + .frame(egui::Frame::NONE) + .lock_focus(true) + .layouter(&mut layouter) + .show(&mut child); + if let Some(c) = accepted { + let mut st = out.state.clone(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c)))); + st.store(ui.ctx(), id); + } + let painter = ui.painter(); + if p.line_numbers { + let g = out.galley.clone(); + let mut line = 1; + let mut new_line = true; + for row in &g.rows { + if new_line { + let y = out.galley_pos.y + row.rect().center().y; + painter.text(pos2(rect.min.x + gutter - 6.0, y), egui::Align2::RIGHT_CENTER, line.to_string(), Tokens::mono(size * 0.85), t.text_faint); + line += 1; + } + new_line = row.ends_with_newline; + } + painter.line_segment([pos2(rect.min.x + gutter - 3.0, rect.min.y), pos2(rect.min.x + gutter - 3.0, rect.max.y)], Stroke::new(1.0, t.separator)); + } + // Candidate list under the editor. + if p.auto_complete + && out.response.has_focus() + && let Some(c) = out.cursor_range.map(|r| r.primary.index.0) + { + let (_, cands) = completions(buf, c); + if !cands.is_empty() { + let pos = out.galley_pos + out.galley.pos_from_cursor(egui::text::CCursor::new(c)).left_bottom().to_vec2() + vec2(0.0, 2.0); + egui::Area::new(id.with("complete")).order(egui::Order::Tooltip).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + for (i, w) in cands.iter().enumerate() { + let txt = if i == 0 { format!("{w} Tab") } else { w.to_string() }; + ui.label(egui::RichText::new(txt).font(Tokens::mono(size * 0.9)).color(if i == 0 { t.text } else { t.text_dim })); + } + }); + }); + } + } + out.response.response +} + +/// Settings ▸ Scripting & Expressions ▸ Show Expression Error Banner: the first failing +/// expression of the comp at the current time, in a banner along the bottom of the viewer +/// (click it to reveal the property). +pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) { + if !app.session.prefs.scripting.error_banner { + return; + } + let Some(cid) = app.session.active_comp_id() else { return }; + let key = (app.session.revision, cid.0, app.session.time().0); + let id = egui::Id::new("expr-error-banner"); + let cached: Option<((u64, u64, i64), Vec)> = ui.ctx().data(|d| d.get_temp(id)); + let errors = match cached { + Some((k, e)) if k == key => e, + _ => { + let e = app.session.expression_errors(cid); + ui.ctx().data_mut(|d| d.insert_temp(id, (key, e.clone()))); + e + } + }; + let Some(first) = errors.first() else { return }; + let layer = first["layer"].as_u64().unwrap_or(0); + let lname = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).map(|l| l.name.clone()).unwrap_or_default(); + let msg = format!( + "⚠ Expression error{}: {} ▸ {}: {}", + if errors.len() > 1 { format!(" (1 of {})", errors.len()) } else { String::new() }, + lname, + first["name"].as_str().unwrap_or(""), + first["error"].as_str().unwrap_or("").lines().next().unwrap_or("") + ); + let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::from_rgb(0xf0, 0xc0, 0x30)); + p.text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, msg, Tokens::ui(11.5), Color32::BLACK); + app.auto.add("viewer.expressionErrorBanner", r, "Expression error banner"); + if ui.interact(r, id.with("click"), egui::Sense::click()).clicked() { + let p = serde_json::json!({"props": [{"layer": layer, "prop": first["prop"]}]}); + if let Err(e) = crate::menus::invoke(app, ui.ctx(), "timeline.revealProps", p) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3cf2dae10..68b13a0b4 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -7,6 +7,7 @@ pub mod dialogs; pub mod dialogs_3d; pub mod effect_controls; pub mod effects_presets; +pub mod expr_editor; pub mod flowchart; pub mod forms; pub mod fx_widgets; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index edb1132ff..5ab0a73a3 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -98,9 +98,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, pop); } app.auto.add("character.font", fr, "Font family"); - let fams: Vec = effectcraft_engine::text_families(); - if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) { - actions.push(json!({"font": fams[i]})); + if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) { + actions.push(json!({"font": f})); } y += 28.0; let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); @@ -349,20 +348,29 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } y += 3.0 * 28.0 + 6.0; - // Direction and composer popups. - let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0)); - let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()]; - let dpop = egui::Id::new("para-direction-pop"); + // Direction and composer popups. Settings ▸ Type ▸ Text Engine: the South Asian and Middle + // Eastern engine offers the paragraph direction and the World-Ready composers (the Latin + // engine shows the direction only for right-to-left text already set). + let world_ready = app.session.prefs.type_.text_engine == "southAsian"; let rtl = doc.direction == Direction::Rtl; - if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled { - widgets::open_popup(ui, dpop); - } - app.auto.add("paragraph.direction", dr, "Text direction"); - if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) { - actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }})); + if world_ready || rtl { + let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0)); + let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()]; + let dpop = egui::Id::new("para-direction-pop"); + if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled { + widgets::open_popup(ui, dpop); + } + app.auto.add("paragraph.direction", dr, "Text direction"); + if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) { + actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }})); + } } let cr = Rect::from_min_size(pos2(x0 + w / 2.0, y), vec2(w / 2.0, 22.0)); - let comps = vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()]; + let comps = if world_ready { + vec!["World-Ready Every-line Composer".to_string(), "World-Ready Single-line Composer".to_string()] + } else { + vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()] + }; let cpop = egui::Id::new("para-composer-pop"); let single = doc.composer == Composer::SingleLine; if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled { @@ -390,6 +398,56 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The font menu: recent fonts first, names in English or the fonts' own language, and a +/// "Sample" preview in each font (Settings ▸ Type). +fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { + if !ui.data(|d| d.get_temp::(id.with("open")).unwrap_or(false)) { + return None; + } + let rows = effectcraft_engine::font_menu(&app.session.prefs); + let preview = app.session.prefs.type_.font_preview; + let t = app.tokens; + let mut chosen = None; + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(if preview { 300.0 } else { 180.0 }); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for r in &rows { + if r.family.is_empty() { + ui.separator(); + continue; + } + let resp = ui.selectable_label(r.family == current, &r.display); + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l + } + }; + let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + } + }); + }); + }); + let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered(); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(id.with("open"), false)); + } + chosen +} + /// Align panel: align selected layers to the composition (or to the selection). pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5eb4caa66..dc6b98e2d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1510,15 +1510,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let buf_id = egui::Id::new(("expr-buf", uid)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); - let resp = child.add( - egui::TextEdit::multiline(&mut buf) - .id(egui::Id::new(("expr-edit", uid))) - .font(Tokens::mono(11.5)) - .text_color(if ex.enabled { expr_col } else { t.text_dim }) - .desired_width(er.width()) - .desired_rows((*lines).clamp(1, 8)) - .frame(egui::Frame::NONE), + // Settings ▸ Scripting & Expressions ▸ Expressions Editor. + let sp = app.session.prefs.scripting.clone(); + let resp = super::expr_editor::editor( + ui, + egui::Id::new(("expr-edit", uid)), + &mut buf, + er, + &sp, + if ex.enabled { expr_col } else { t.text_dim }, + (*lines).clamp(1, 8), + &t, ); app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); @@ -1647,7 +1649,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ks = sel_keys.contains(&kref); let icon = k.icon(); let c = pos2(x, cy); - icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200)); + // Keyframe colour labels (Edit ▸ Label on selected keyframes). + let fill = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) { + Some(l) if ks => t.label(*l).lerp_to_gamma(Color32::WHITE, 0.45), + Some(l) => t.label(*l), + None if ks => t.keyframe_selected, + None => t.keyframe, + }; + icons::keyframe(&gp, c, 11.0, icon.left, icon.right, fill, Color32::from_black_alpha(200)); let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index c9dd70dbf..2057df997 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -423,12 +423,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let sub = g.grid_subdivisions.max(1) as f32; let sstep = step / sub; let draw_minor = sstep >= 4.0; + // Settings ▸ Grids & Guides ▸ Grid Style (lines, dashed lines or dots). + let style = g.grid_style.clone(); let mut i = 0u32; let mut gx = comp_rect.min.x; while gx <= comp_rect.max.x { let is_major = i.is_multiple_of(g.grid_subdivisions.max(1)); if is_major || draw_minor { - painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }); + styled_line(&painter, [pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }, &style); } gx += sstep; i += 1; @@ -438,7 +440,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { while gy <= comp_rect.max.y { let is_major = i.is_multiple_of(g.grid_subdivisions.max(1)); if is_major || draw_minor { - painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }); + styled_line(&painter, [pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }, &style); } gy += sstep; i += 1; @@ -451,13 +453,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b)); + // Settings ▸ Grids & Guides ▸ Guide Style. + let style = app.session.prefs.grids.guide_style.clone(); for g in &comp.guides { if g.vertical { let x = comp_rect.min.x + g.position as f32 * zoom; - painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], stroke); + styled_line(&painter, [pos2(x, area.min.y), pos2(x, area.max.y)], stroke, &style); } else { let y = comp_rect.min.y + g.position as f32 * zoom; - painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], stroke); + styled_line(&painter, [pos2(area.min.x, y), pos2(area.max.x, y)], stroke, &style); } } } @@ -501,6 +505,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hover = ui.input(|i| i.pointer.hover_pos()); ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths); } + // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a + // selected collapsed precomp. + if app.session.prefs.previews.show_internal_wireframes + && l.switches.collapse + && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source + && let Some(nc) = app.session.project.comp(*item) + { + let (outer, _) = l2c(&ectx, l); + let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.source_time(l)); + for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) { + if let Some((_, q, _)) = layer_quad(&nctx, nl) { + let sq: Vec = q + .iter() + .map(|p| { + let c = outer.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, col.gamma_multiply(0.6)))); + } + } + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // Cameras, lights, empty layers: just the anchor. if let Some(tr) = l.transform() { @@ -549,6 +575,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Settings ▸ 3D: Extended Viewer (3D layers outlined beyond the comp frame) and Show 3D + // Reference Axes (world X/Y/Z in the corner, turning with the view). + let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); + if has_3d && app.session.prefs.three_d.extended_viewer { + for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) { + if let Some((_, q, _)) = layer_quad(&ectx, l) { + let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect(); + let outside = sq.iter().any(|p| !comp_rect.contains(*p)); + if outside { + painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, t.label(l.label).gamma_multiply(0.55)))); + } + } + } + } + if has_3d && app.session.prefs.three_d.show_reference_axes { + let pc = cam_state(&ectx).projection(cw as f64, ch as f64); + let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0); + let o = pc.apply(o3); + let corner = pos2(area.min.x + 34.0, area.max.y - 34.0); + for (axis, col, name) in [ + (effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "X"), + (effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "Y"), + (effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x8c, 0xf0), "Z"), + ] { + let e = pc.apply(o3 + axis * 10.0); + let d = vec2((e.x - o.x) as f32, (e.y - o.y) as f32); + let d = if d.length() > 1e-4 { d.normalized() * 22.0 } else { vec2(0.0, 0.0) }; + painter.arrow(corner, d, Stroke::new(1.5, col)); + painter.text(corner + d * 1.25, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col); + } + app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes"); + } + + super::expr_editor::error_banner(app, ui, area); + // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); @@ -1437,6 +1498,26 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } /// `#rrggbb` → sRGB bytes. +/// A grid or guide line in the Grids & Guides style: `lines`, `dashed` or `dots`. +pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Stroke, style: &str) { + match style { + "dashed" => { + painter.add(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + } + "dots" => { + painter.add(egui::Shape::dotted_line(&pts, stroke.color, 4.0, stroke.width.max(1.0) * 0.75)); + } + _ => { + painter.line_segment(pts, stroke); + } + } +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. +pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { + if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } +} + pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { let h = s.trim().trim_start_matches('#'); if h.len() != 6 || !h.is_ascii() { @@ -1450,14 +1531,17 @@ pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { /// straight from their wgpu texture (registered with egui-wgpu, no readback). fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { use crate::frames::FrameImage; + // Settings ▸ Previews ▸ Viewer Zoom Quality. + let smooth = app.session.prefs.viewer_zoom_smooth(); + let opts = zoom_texture_options(smooth); match img { FrameImage::Cpu(img) => { match &mut app.viewer_tex { Some((tex, k)) if tex.size() == img.size => { - tex.set((*img).clone(), egui::TextureOptions::LINEAR); + tex.set((*img).clone(), opts); *k = key; } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)), + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)), } app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); app.viewer_image = Some(img); @@ -1467,12 +1551,13 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); let mut ren = rs.renderer.write(); + let filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; let id = match &app.viewer_native { Some((id, _, _)) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear, *id); + ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id); *id } - None => ren.register_native_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear), + None => ren.register_native_texture(&rs.device, &view, filter), }; drop(ren); app.viewer_native = Some((id, key, f)); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 08cc32b52..95181d7cd 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -222,7 +222,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) }; - let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time); + // Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI. + let times: Vec = pos.keys.iter().map(|k| k.time).collect(); + let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, l.layer_time(ectx.time)) else { return }; let fd = ectx.comp.frame_duration(); let mut pts = vec![]; let mut lt = lt0; @@ -238,6 +240,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map } let sel = &app.session.state.selected_keys; for (i, k) in pos.keys.iter().enumerate() { + if k.time < lt0 || k.time > lt1 { + continue; + } let v = k.value.as_vec3(); let s = to_scr(v); let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 79688e193..ac61a4a26 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -282,11 +282,11 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1)); for i in 1..nx { let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32; - painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s); + super::viewer::styled_line(painter, [pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s, &g.grid_style); } for i in 1..ny { let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32; - painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s); + super::viewer::styled_line(painter, [pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s, &g.grid_style); } } @@ -319,6 +319,11 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) { let opts = app.session.state.viewer.clone(); let snap = showing_snapshot(app, ctx); + // Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only. + if crate::prefs_live::main_viewer_hidden(app) && !snap { + painter.text(comp_rect.center(), Align2::CENTER_CENTER, "Playing on Video Preview", Tokens::ui(13.0), Color32::from_gray(150)); + return; + } if opts.fast_previews == FastPreviews::Wireframe && !snap { let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32; let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect }; @@ -375,6 +380,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: return; } let Some(src) = app.viewer_image.clone() else { return }; + let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -385,7 +391,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR); + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), zoom_opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } diff --git a/crates/ui-egui/src/panels/waveform.rs b/crates/ui-egui/src/panels/waveform.rs index f9bf00db1..ab3fbd790 100644 --- a/crates/ui-egui/src/panels/waveform.rs +++ b/crates/ui-egui/src/panels/waveform.rs @@ -47,10 +47,25 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio let ItemKind::Footage(f) = &app.session.project.item(item)?.kind else { return None }; m.insert(item.0, None); let (f, footage, map, ctx) = (f.clone(), app.session.footage.clone(), app.waveforms.map.clone(), ctx.clone()); + // Settings ▸ Disk ▸ Database and Cache Folder: summaries are kept on disk between sessions. + let cache = app.session.media_cache_folder(); std::thread::Builder::new() .name("ec-waveform".into()) .spawn(move || { - let peaks = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC); + use effectcraft_engine::media_cache; + let file = cache.and_then(|d| media_cache::peaks_path(&d, &f.path, BINS_PER_SEC)); + let peaks = match file.as_deref().and_then(media_cache::load_peaks) { + Some(p) => p, + None => { + let p = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC); + if let Some(file) = &file + && let Err(e) = media_cache::store_peaks(file, &p) + { + log::warn!("media cache: {}: {e}", file.display()); + } + p + } + }; if let Ok(mut m) = map.lock() { m.insert(item.0, Some(Arc::new(Summary { bins_per_sec: BINS_PER_SEC, peaks }))); } diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs new file mode 100644 index 000000000..9e730196d --- /dev/null +++ b/crates/ui-egui/src/prefs_live.rs @@ -0,0 +1,141 @@ +//! Settings the desktop frontend applies every frame: dropped files (Import ▸ Default Drag +//! Import As), the memory watch (Memory & CPU ▸ RAM Reserved / Reduce Cache Size When System Is +//! Low on Memory), Startup ▸ Show Home Screen When Opening a Project, and Video ▸ Video Preview +//! output (a second window showing the composition frame, Mercury Transmit-style). + +use egui::{Color32, Rect}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; +use crate::frames::FrameKey; + +/// Run once per frame, before the panels draw. +pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { + dropped_files(app, ctx); + memory_watch(app, ctx); + home_on_open(app, ctx); + video_preview(app, ctx); +} + +/// Files dropped on the window are imported (layered files as Default Drag Import As says). +fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { + let paths: Vec = + ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect()); + if paths.is_empty() { + return; + } + let (proj, rest): (Vec, Vec) = paths.into_iter().partition(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".ecproj") || l.ends_with(".ecprojx") + }); + if let Some(p) = proj.first() + && let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p})) + { + app.ui.status = e; + } + if !rest.is_empty() { + let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as()}); + if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { + app.ui.status = e; + } + } +} + +/// Re-read the system's free memory every few seconds (cache budgets follow it). +fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + let id = egui::Id::new("prefs-memory-watch"); + let last = ctx.data(|d| d.get_temp::(id)); + if last.is_some_and(|t| now - t < 10.0) { + return; + } + ctx.data_mut(|d| d.insert_temp(id, now)); + // The first frame only records the time: tests and short runs don't query the system. + if last.is_some() { + app.session.memory_tick(); + } +} + +/// Startup ▸ Show Home Screen When Opening a Project. +fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) { + let id = egui::Id::new("prefs-last-project"); + let path = app.session.path.clone(); + let last = ctx.data(|d| d.get_temp::>(id)); + ctx.data_mut(|d| d.insert_temp(id, path.clone())); + let Some(last) = last else { return }; + let opened = app.session.journal.last().is_some_and(|(c, _)| matches!(c.as_str(), "file.open" | "file.openRecent" | "file.revert")); + if path.is_some() && path != last && opened && app.session.prefs.startup.show_home_on_open_project { + app.ui.start_screen = true; + } +} + +/// Video ▸ Mirror on Computer Monitor off: during playback the Composition panel leaves the +/// frame to the Video Preview window. +pub fn main_viewer_hidden(app: &EffectcraftApp) -> bool { + let v = &app.session.prefs.video; + v.enable_output && !v.mirror_on_monitor && app.playback.playing +} + +/// Whether the Video Preview window shows now (Disable Video Output When in Background). +pub fn video_preview_active(app: &EffectcraftApp, focused: bool) -> bool { + let v = &app.session.prefs.video; + v.enable_output && (focused || !v.disable_when_background) +} + +/// Video ▸ Enable Video Preview Output: a second native window with the current composition +/// frame, letterboxed on black. Video Device "Full Screen" opens it full screen (move it to the +/// external display first); Video Output During Playback off holds the last frame while playing. +fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + if !video_preview_active(app, focused) { + return; + } + let v = app.session.prefs.video.clone(); + let tex_id = egui::Id::new("video-preview-tex"); + let update = !app.playback.playing || v.output_during_playback; + let key = app.viewer_shown.as_ref().map(|(_, k)| *k); + let cur: Option<(FrameKey, egui::TextureHandle)> = ctx.data(|d| d.get_temp(tex_id)); + let tex = match (update, key, cur) { + (true, Some(k), Some((ck, t))) if ck == k => Some(t), + (true, Some(k), prev) => match app.viewer_pixels() { + Some(img) => { + let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let t = match prev { + Some((_, mut t)) => { + t.set((*img).clone(), opts); + t + } + None => ctx.load_texture("video-preview", (*img).clone(), opts), + }; + ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone()))); + Some(t) + } + None => prev.map(|p| p.1), + }, + (_, _, prev) => prev.map(|p| p.1), + }; + let title = + format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default()); + let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]); + if v.device == "fullscreen" { + builder = builder.with_fullscreen(true); + } + let mut closed = false; + ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| { + let area = ui.max_rect(); + if let Some(t) = &tex { + let [w, h] = t.size().map(|x| x.max(1) as f32); + let s = (area.width() / w).min(area.height() / h); + let r = Rect::from_center_size(area.center(), egui::vec2(w * s, h * s)); + ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + }); + if vctx.input(|i| i.viewport().close_requested()) { + closed = true; + } + }); + if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) { + app.ui.status = e; + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c32ecb248..eb02bf486 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -75,6 +75,29 @@ pub struct Tokens { pub gap: f32, pub tab_h: f32, pub row_h: f32, + /// Settings ▸ Appearance ▸ Use Gradients: panel tab strips and buttons get a soft vertical + /// gradient (off: flat fills). + pub gradients: bool, +} + +/// A vertical gradient fill (`top` → `bottom`) over `rect`. +pub fn gradient_rect(painter: &egui::Painter, rect: egui::Rect, top: Color32, bottom: Color32) { + let mut m = egui::Mesh::default(); + m.colored_vertex(rect.left_top(), top); + m.colored_vertex(rect.right_top(), top); + m.colored_vertex(rect.right_bottom(), bottom); + m.colored_vertex(rect.left_bottom(), bottom); + m.add_triangle(0, 1, 2); + m.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(m)); +} + +impl Tokens { + /// The gradient top colour for a fill (a little lighter), or the fill itself when gradients + /// are off. + pub fn grad_top(&self, c: Color32) -> Color32 { + if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } + } } impl Tokens { @@ -123,6 +146,7 @@ impl Tokens { gap: 4.0, tab_h: 30.0, row_h: 22.0, + gradients: true, }; match kind { ThemeKind::Dark => dark, @@ -192,6 +216,7 @@ impl Tokens { let [r, g, b] = p.label_rgb(*l); t.labels[i] = Color32::from_rgb(r, g, b); } + t.gradients = p.appearance.use_gradients; t } diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 2c9437405..2c9af6081 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -134,7 +134,9 @@ fn double_click_enters_editing_and_selects_words() { assert_eq!(edited(&h).1, "alpha gamma"); key(&mut h, Key::Z, Modifiers::COMMAND); assert_eq!(edited(&h).1, "alpha beta"); - // Paragraph panel: alignment applies to the edited paragraph. + // Paragraph panel: alignment applies to the edited paragraph (the South Asian and Middle + // Eastern text engine shows the direction popup too). + h.state_mut().session.prefs.type_.text_engine = "southAsian".into(); h.state_mut().show_panel(PanelKind::Paragraph); h.run_steps(3); let r = rect(&h, "paragraph.right"); diff --git a/docs/preferences.md b/docs/preferences.md index cb05229ef..57400a310 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -70,71 +70,79 @@ Commands (CLI, MCP, control channel): - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles +- `general.switchesAffectNestedComps`: a precomp layer's Quality (Draft / Wireframe) and Motion + Blur switches limit the layers of the nested comp (viewer, Render Queue, `render`) +- `general.preserveConstantVertexCount`: adding or deleting mask / shape path vertices at one + keyframe does the same on every other keyframe (added vertices split the matching segment) +- `general.syncTimeRelatedItems`: moving the current time moves it in the comps nested in this + one and in the comps that nest it (through the precomp layers' timing) +- `general.expressionPickWhipCompact`: off, the pick whip writes match names + (`thisComp.layer("A")("transform")("opacity")`) +- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer +- `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent +- `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`: + how much of a position motion path the viewer draws (all, none, N seconds or N keyframes + around the current time) +- `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp +- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear +- `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour, + Effect Controls / Properties the layer's +- `appearance.cycleMaskColors`: new masks cycle through the mask colours (off: all the first) +- `appearance.useGradients`: soft gradient on panel tab strips +- `grids.gridStyle`, `grids.guideStyle`: lines, dashed lines or dots (grid, proportional grid, + guides); `grids.proportionalHorizontal` / `grids.proportionalVertical`: View ▸ Show + Proportional Grid divisions +- `type.textEngine`: South Asian and Middle Eastern shows the paragraph direction and the + World-Ready composers in the Paragraph panel +- `type.fontPreview`: a "Sample" preview in each font of the Character panel's font menu +- `type.recentFonts`: how many recently used fonts head the font menu +- `type.fontNamesInEnglish`: off, fonts with a native-language family name show it +- `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import +- `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; + Guess takes premultiplied for movies, straight for stills) +- `import.dragImportAs`: layered files dropped on the window import as footage or a comp +- `export.defaultOutputFolder`: where relative Render Queue outputs go +- `export.segmentSequences`, `export.segmentSequenceFiles`: sequences split into numbered + folders of N files; `export.segmentMovies`, `export.segmentMovieMb`: movies split into + numbered files of about N MB (cut at the frame count the output's data rate gives) +- `export.appendBitsToName`: `Comp 1.png` → `Comp 1_16bpc.png` +- `audio.previewSampleRate`: the rate previews are mixed at (and the device is opened at, when + it supports it) +- `disk.diskCacheEnabled`, `disk.diskCacheMaxGb`, `disk.diskCacheFolder`: the persistent disk + cache of processed layers +- `disk.mediaCacheFolder`: audio waveform summaries kept between sessions (`Peaks/`) +- `disk.conformedMediaFolder`: decoded (conformed) footage audio, written once per file and + sample rate and read back instead of decoding again +- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free +- `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked + every 10 s by the desktop app) +- `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`, + `video.disableWhenBackground`: Video Preview, a second window (or full screen on the display + it is on) showing the composition frame, Mercury Transmit-style +- `threeD.showReferenceAxes`: world X/Y/Z axes in the viewer corner of comps with 3D layers +- `threeD.extendedViewer`: 3D layers reaching beyond the comp frame are outlined on the + pasteboard +- `threeD.realtimeShadows`: draft (Fast Previews) renders keep 3D shadows +- `scripting.allowScriptsWriteFiles`: the scripting file / network gate (and `File.execute()`) +- `scripting.warnExecutingFiles`: `File.execute()` asks before opening a file +- `scripting.editorFontSize`, `scripting.syntaxHighlighting`, `scripting.lineNumbers`, + `scripting.autoComplete` (Tab accepts), `scripting.bracketMatching`, `scripting.wordWrap`: + the Timeline's expression editor +- `scripting.errorBanner`: a banner along the bottom of the Composition panel names the first + failing expression (click: reveal it) -### Not wired yet (TODO) +### Display-only settings -These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet. -`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema. +These rows are in the dialog for After Effects parity and are stored, but have no EffectCraft +behaviour to change. `prefs.pages` reports them with `"live": false`; a test keeps this list in +step with the schema. -- `general.switchesAffectNestedComps`: Switches Affect Nested Comps -- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks -- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items -- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English -- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer -- `general.useSystemColorPicker`: Use System Color Picker -- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project -- `composition.motionPath`: Motion Path -- `composition.motionPathSeconds`: Seconds -- `composition.motionPathKeyframes`: Keyframes -- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels -- `previews.showInternalWireframes`: Show Internal Wireframes -- `previews.zoomQuality`: Viewer Zoom Quality -- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs -- `appearance.cycleMaskColors`: Cycle Mask Colors -- `appearance.useGradients`: Use Gradients -- `grids.gridStyle`: Style -- `grids.proportionalHorizontal`: Horizontal -- `grids.proportionalVertical`: Vertical -- `grids.guideStyle`: Style -- `type.textEngine`: Text Engine -- `type.fontPreview`: Show Font Preview -- `type.recentFonts`: Number of Recent Fonts to Display -- `type.fontNamesInEnglish`: Show Font Names in English -- `import.reportMissingFrames`: Report Missing Frames -- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As -- `import.dragImportAs`: Default Drag Import As -- `export.defaultOutputFolder`: Default Output Folder -- `export.segmentSequences`: Segment Sequences -- `export.segmentSequenceFiles`: Files per Segment -- `export.segmentMovies`: Segment Movie Files -- `export.segmentMovieMb`: Segment Size -- `export.appendBitsToName`: Append Bit Depth to File Name -- `audio.previewSampleRate`: Preview Sample Rate -- `disk.diskCacheEnabled`: Enable Disk Cache -- `disk.diskCacheMaxGb`: Maximum Disk Cache Size -- `disk.diskCacheFolder`: Disk Cache Folder -- `disk.mediaCacheFolder`: Database and Cache Folder -- `disk.conformedMediaFolder`: Conformed Audio Folder -- `memory.ramReservedGb`: RAM Reserved for Other Applications -- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory -- `video.enableOutput`: Enable Video Preview Output -- `video.device`: Video Device -- `video.outputDuringPlayback`: Video Output During Playback -- `video.mirrorOnMonitor`: Mirror on Computer Monitor -- `video.disableWhenBackground`: Disable Video Output When in Background -- `threeD.showReferenceAxes`: Show 3D Reference Axes -- `threeD.extendedViewer`: Extended Viewer -- `threeD.realtimeShadows`: Realtime Shadows in Draft -- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network -- `scripting.warnExecutingFiles`: Warn User When Executing Files -- `scripting.enableJsDebugger`: Enable JavaScript Debugger -- `scripting.editorFontSize`: Font Size -- `scripting.syntaxHighlighting`: Syntax Highlighting -- `scripting.lineNumbers`: Line Numbers -- `scripting.autoComplete`: Auto-complete -- `scripting.bracketMatching`: Bracket Matching -- `scripting.wordWrap`: Word Wrap -- `scripting.errorBanner`: Show Expression Error Banner +- `general.useSystemColorPicker`: EffectCraft has one colour picker on every platform and the + web; there is no pure-Rust way to open the system colour panels. +- `composition.hardwareAcceleratePanels`: the Composition, Layer and Footage panels are always + drawn by the GPU (egui on wgpu). +- `scripting.enableJsDebugger`: the JavaScript engine has no step debugger; script errors report + their file and line in the Script Console. ## Keyboard shortcuts From c8623b1864e0c9a04b09bae8427e3f75feebdfa7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:46:09 -0400 Subject: [PATCH 08/20] M12.7: GPU blur/transition/generate effects (radial, camera lens, CC radial fast, wipes, venetian, cell pattern, checkerboard, grid, 4-color, noise, add grain) Kernels in fx_generate.wgsl mirror the CPU effects; wipe edges use f64 per-column/per-row terms uploaded as (hi, lo) pairs. CPU helpers shared with the GPU are now public (camera_lens_spans, gen_mode, pattern_kind, GrainLook, place_layer). GPU tests in tests_fx_generate.rs. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 18 +- crates/effects/src/generate.rs | 2 +- crates/effects/src/generate2.rs | 2 +- crates/effects/src/lib.rs | 20 + crates/effects/src/noise.rs | 4 +- crates/effects/src/transition.rs | 2 +- crates/gpu/src/fx_generate.rs | 400 ++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_generate.wgsl | 629 ++++++++++++++++++++++++ crates/gpu/src/tests_fx_generate.rs | 200 ++++++++ docs/effects.md | 28 +- 11 files changed, 1275 insertions(+), 32 deletions(-) create mode 100644 crates/gpu/src/tests_fx_generate.rs diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee4..3b84d2830 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -277,11 +277,9 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64 spans } -fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; - if r < 0.5 { - return b; - } +/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and +/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel). +pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) { let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); @@ -293,7 +291,15 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { s.2 = (s.2 as f64 * aspect).round() as i64; } } - let reach = r * aspect.max(1.0); + (spans, r * aspect.max(1.0)) +} + +fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return b; + } + let (spans, reach) = camera_lens_spans(ctx, r); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { b.pad(reach.ceil() as u32 + 1); diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 511a8fea1..09b25a276 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [ "Silhouette Alpha", ]; -fn gen_mode(i: u32) -> Option { +pub fn gen_mode(i: u32) -> Option { Some(match i { 0 => return None, 2 => BlendMode::Add, diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a32..ddde36d73 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ ]; /// Shaping used for Cell Pattern option `i` (see [`cell_value`]). -fn pattern_kind(i: u32) -> u32 { +pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, 6 => 1, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f3465..4f3472316 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -67,6 +67,12 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +// CPU helpers the GPU kernels share (effectcraft-gpu). +pub use blur2::camera_lens_spans; +pub use generate::gen_mode; +pub use generate2::pattern_kind as cell_pattern_kind; +pub use noise::GrainLook; +pub use transition::place_layer; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -641,6 +647,20 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + // Blur, transition and generate family (effectcraft-gpu `fx_generate`). + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a2..4181c0708 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -41,7 +41,7 @@ pub(crate) const GRAIN_BLEND_MODES: [&str; 5] = ["Film", "Multiply", "Add", "Scr /// The Tweaking / Color / Application / Animation controls Add Grain and Match Grain share /// (twirl-down groups with the same ids in both effects). -pub(crate) struct GrainLook { +pub struct GrainLook { pub intensity: f32, pub channel_intensity: [f32; 3], pub size: f32, @@ -62,7 +62,7 @@ pub(crate) struct GrainLook { } impl GrainLook { - pub(crate) fn from_params(ctx: &EffectCtx) -> GrainLook { + pub fn from_params(ctx: &EffectCtx) -> GrainLook { let pr = ctx.params; let ch = |pre: &str, a: &str, b: &str, c: &str| [a, b, c].map(|k| pr.get(&format!("{pre}{k}")).map(Value::as_f64).unwrap_or(1.0) as f32); let ft = ctx.time * 24.0 * pr.f("animation/animationSpeed"); diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449d..9701f0c44 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -30,7 +30,7 @@ fn scale_px(px: &mut Px, k: f32) { /// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient /// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once, /// 2 stretch to fit. -pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { +pub fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { let (ls, os) = (ctx.layer_size, other.size); let inv = 1.0 / b.scale.max(1e-9); crate::util::gen_image(b.img.width, b.img.height, |x, y| { diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 61c18defc..976bfc8e8 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -1,18 +1,404 @@ //! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard +//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as +//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does. -use effectcraft_effects::EffectCtx; +use effectcraft_color::BlendMode; +use effectcraft_effects::{EffectCtx, GrainLook}; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_generate.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "gen_radial_blur", + "gen_ccradialfast", + "gen_lens_pre", + "gen_row_prefix", + "gen_lens_blur", + "gen_linear_wipe", + "gen_venetian", + "gen_radial_wipe", + "gen_gradient_wipe", + "gen_cell_pattern", + "gen_checker", + "gen_grid", + "gen_fourcolor", + "gen_noise", + "gen_grain_planes", + "gen_grain_apply", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", +]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. -pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.blur.radial" => radial_blur(e, ctx, b), + "ec.blur.cameralens" => camera_lens(e, ctx, b), + "ec.blur.ccradialfast" => radial_fast(e, ctx, b), + "ec.transition.venetian" => venetian(e, ctx, b), + "ec.transition.linearwipe" => linear_wipe(e, ctx, b), + "ec.transition.radialwipe" => radial_wipe(e, ctx, b), + "ec.transition.gradientwipe" => gradient_wipe(e, ctx, b), + "ec.generate.cellpattern" => cell_pattern(e, ctx, b), + "ec.generate.checkerboard" => checkerboard(e, ctx, b), + "ec.generate.grid" => grid(e, ctx, b), + "ec.generate.fourcolor" => four_color(e, ctx, b), + "ec.noise.noise" => noise(e, ctx, b), + "ec.noise.addgrain" => add_grain(e, ctx, b), + _ => None, + } +} + +/// Run a per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option { + let buf = data.map(|d| e.data(d)); + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// `v` as an f32 (hi, lo) pair. +fn hl(v: f64) -> [f32; 2] { + let h = v as f32; + [h, (v - h as f64) as f32] +} + +/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace). +fn gen_mode(i: u32) -> u32 { + match effectcraft_effects::gen_mode(i) { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + } +} + +// ---------------------------------------------------------------- blurs + +fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount"); + let zoom = ctx.params.e("type") == 1; + let c = b.to_px(ctx.params.v2("center")); + let amount = if zoom { amt / 100.0 } else { amt }; + if amount.abs() < 1e-6 { + return Some(b); + } + let n = 32usize; + let mut data = Vec::with_capacity(2 * n); + for i in 0..n { + let t = i as f64 / (n - 1) as f64 - 0.5; + if zoom { + data.push((1.0 + t * amount) as f32); + } else { + let (s, c) = (t * amount).to_radians().sin_cos(); + data.extend([s as f32, c as f32]); + } + } + let mut p = Params::default(); + p.u[0] = [zoom as u32, n as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0]; + run(e, "gen_radial_blur", &p, b, None, Some(&data)) +} + +fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; + if s <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.u[0][0] = ctx.params.e("zoom"); + p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0]; + run(e, "gen_ccradialfast", &p, b, None, None) +} + +fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return Some(b); + } + let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + if !repeat { + b.pad(e, reach.ceil() as u32 + 1)?; + } + let linear = ctx.params.b("useLinear"); + let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0; + let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0; + let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width, b.img.height); + let mut src = b.img.clone(); + if linear || boost > 1.0 { + let mut p = Params::default(); + p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0]; + p.f[0] = [thr, boost, sat, 0.0]; + let out = e.image(w, h); + e.pixels("gen_lens_pre", &p, &src, None, &out, None); + src = out; + } + let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); + let norm = 1.0 / count as f64; + // Large irises sum each span from per-row prefix sums; small ones add the pixels directly. + let prefix = count > 256; + let pre = if prefix { + if !e.g.fits(w + 1, h) { + return None; + } + let pre = e.image(w + 1, h); + e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + Some(pre) + } else { + None + }; + let data: Vec = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect(); + let mut p = Params::default(); + p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32]; + p.f[0][0] = norm as f32; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- transitions + +fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("angle").to_radians(); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (dx, dy) = (ang.sin(), -ang.cos()); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); + let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); + let edge = lo - feather + (hi - lo + 2.0 * feather) * done; + let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + data.extend(hl((x as f64 + 0.5) * dx - edge)); + } + for y in 0..b.img.height { + data.extend(hl((y as f64 + 0.5) * dy)); + } + let mut p = Params::default(); + p.u[0][0] = 2 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_linear_wipe", &p, b, None, Some(&data)) +} + +fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("direction").to_radians(); + let width = (ctx.params.f("width") * b.scale).max(1.0); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (dx, dy) = (ang.cos(), ang.sin()); + let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize); + for x in 0..b.img.width { + let cx = ((x as f64 + 0.5) * dx).rem_euclid(width); + data.extend(hl(cx - done * width)); + data.extend(hl(cx - width - done * width)); + data.extend(hl(cx - width)); + } + for y in 0..b.img.height { + data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width))); + } + let mut p = Params::default(); + p.u[0][0] = 6 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_venetian", &p, b, None, Some(&data)) +} + +fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let dir = ctx.params.e("wipe"); + let feather = ctx.params.f("feather").max(0.01); + let edge = if dir == 2 { done * 180.0 } else { done * 360.0 }; + // Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies). + let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos()); + let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + let ux = x as f64 + 0.5 - c.0; + for (s, co) in rots { + data.extend(hl(ux * co)); + data.push((ux * s) as f32); + } + } + for y in 0..b.img.height { + let uy = -(y as f64 + 0.5 - c.1); + for (s, co) in rots { + data.extend(hl(uy * s)); + data.push((uy * co) as f32); + } + } + let mut p = Params::default(); + p.u[0] = [9 * b.img.width, dir.min(2), 0, 0]; + p.f[0] = [edge as f32, feather as f32, 0.0, 0.0]; + run(e, "gen_radial_wipe", &p, b, None, Some(&data)) +} + +fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32; + if done <= 0.0 { + return Some(b); + } + let soft = (ctx.params.f("softness") / 100.0) as f32; + // The gradient layer is fetched and placed on the CPU (as the CPU effect does). + let grad = match ctx.layer_param("gradientLayer", false) { + Some(o) => { + // place_layer reads only the buffer's size, offset and scale. + let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] }; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement")); + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0] = [done, soft, 0.0, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- generate + +fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(1.0); + let off = b.to_px(pr.v2("offset")); + let mut evo = pr.f("evolution") / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0)); + } + let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; + let tile = pr.b("tilingOptions/enableTiling"); + let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64; + let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64; + if nx > i32::MAX as i64 || ny > i32::MAX as i64 { + return None; + } + let mut p = Params::default(); + p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed]; + p.u[1] = [tile as u32, nx as u32, ny as u32, 0]; + // cos / sin of 2π·(h + evo) only see evo modulo 1. + p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32]; + p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0]; + run(e, "gen_cell_pattern", &p, b, None, None) +} + +/// generate::cell_size. +fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) { + let (w, h) = match ctx.params.e("sizeFrom") { + 0 => { + let c = b.to_px(ctx.params.v2("corner")); + ((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs()) + } + 1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale), + _ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale), + }; + (w.max(1.0), h.max(1.0)) +} + +fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0][0] = gen_mode(ctx.params.e("blendingMode")); + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_checker", &p, b, None, None) +} + +fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let border = (ctx.params.f("border") * b.scale).max(0.0); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0]; + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_grid", &p, b, None, None) +} + +fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + for i in 0..4 { + let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1))); + p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; + p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); + } + let blend = ctx.params.f("blend").max(1.0); + let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; + p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + run(e, "gen_fourcolor", &p, b, None, None) +} + +// ---------------------------------------------------------------- noise + +fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool()); + let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed; + let mut p = Params::default(); + p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width]; + p.f[0][0] = amt; + run(e, "gen_noise", &p, b, None, None) +} + +fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let look = GrainLook::from_params(ctx); + if look.intensity <= 0.0 { + return Some(b); + } + let (w, h) = (b.img.width, b.img.height); + // GrainLook::planes. + let size = look.size * b.scale as f32; + let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.seed, 101, 0]; + p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame]; + p.f[1] = [s(0), s(1), s(2), 0.0]; + let mut planes = e.image(w, h); + e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None); + let soft = look.softness * b.scale; + if soft > 0.05 { + planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true); + } + // GrainLook::color's tint factors. + let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3); + let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0]; + p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0]; + p.f[1] = [tint[0], tint[1], tint[2], 0.0]; + p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0]; + p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0]; + run(e, "gen_grain_apply", &p, b, Some(&planes), None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce2090..2da1af203 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_generate; diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index bc67b93ae..e5e2f4111 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -1,2 +1,631 @@ // GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// +// Hard edges (wipes) take per-column and per-row terms computed in f64 on the CPU and uploaded +// as (hi, lo) f32 pairs in `data`: near an edge the hi parts cancel exactly, so the edge sits +// where the CPU's f64 arithmetic puts it. + +const GEN_DEG: f32 = 57.29577951308232; + +// util::unpremul: (straight colour, alpha). +fn gen_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// util::smoothstep. +fn gen_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +// generate::gen_blend: mode = index into BlendMode::ALL, 255 = None (replace). +fn gen_blend(o: vec4, g: vec4, mode: u32) -> vec4 { + if (mode == 255u) { + return g; + } + return blend_pixel(mode, o, g, 0.5); +} + +// A (hi, lo) pair from `data`. +fn gen_hl(i: u32) -> vec2 { + return vec2(data[i], data[i + 1u]); +} + +// (a + b) for (hi, lo) pairs whose hi parts cancel near zero. +fn gen_sum(a: vec2, b: vec2) -> f32 { + return (a.x + b.x) + (a.y + b.y); +} + +// ---------------------------------------------------------------- Radial Blur (raster::radial_blur) + +// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom). +@compute @workgroup_size(16, 16) +fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let zoom = P.u[0].x != 0u; + let n = P.u[0].y; + let c = P.f[0].xy; + let v = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var s: vec2; + if (zoom) { + let k = data[i]; + s = vec2(c.x + v.x * k, c.y + v.y * k); + } else { + let sn = data[2u * i]; + let cs = data[2u * i + 1u]; + s = vec2(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs); + } + acc += sample_bilinear(src, s.x, s.y); + } + textureStore(out, p, acc / f32(n)); +} + +// ---------------------------------------------------------------- CC Radial Fast Blur + +// u[0].x = zoom mode (0 standard, 1 brightest, 2 darkest); f[0] = (cx, cy, s). +@compute @workgroup_size(16, 16) +fn gen_ccradialfast(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let mode = P.u[0].x; + let c = P.f[0].xy; + let s = P.f[0].z; + let d = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + let len = sqrt(d.x * d.x + d.y * d.y) * s; + let n = clamp(u32(ceil(len)), 2u, 64u); + var acc = vec4(0.0); + if (mode == 1u) { + acc = vec4(-3.0e38); + } else if (mode == 2u) { + acc = vec4(3.0e38); + } + for (var i = 0u; i < n; i++) { + let k = 1.0 - s * f32(i) / f32(n - 1u); + let q = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k); + if (mode == 1u) { + acc = max(acc, q); + } else if (mode == 2u) { + acc = min(acc, q); + } else { + acc += q / f32(n); + } + } + let a = acc.w; + textureStore(out, p, vec4(max(min(acc.xyz, vec3(a)), vec3(0.0)), clamp(a, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Camera Lens Blur + +// Linear working space and highlight boost before the blur. +// u[0] = (linear, boost); f[0] = (threshold, boost, saturation). +@compute @workgroup_size(16, 16) +fn gen_lens_pre(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var px = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + let u = gen_unpremul(px); + px = vec4(srgb_to_linear(u.x) * u.w, srgb_to_linear(u.y) * u.w, srgb_to_linear(u.z) * u.w, u.w); + } + if (P.u[0].y != 0u) { + let u = gen_unpremul(px); + let c = u.xyz; + let a = u.w; + if (a > 0.0 && luminance(c) >= P.f[0].x) { + let boost = P.f[0].y; + let sat = P.f[0].z; + let m = max(max(c.x, c.y), c.z); + let tinted = c + (m - c) * (1.0 - sat); + px = vec4((c + tinted * (boost - 1.0)) * a, px.w); + } + } + textureStore(out, p, px); +} + +// Per-row prefix sums: out(x + 1, y) = sum of src(0..=x, y), out(0, y) = 0. One thread per row. +@compute @workgroup_size(64, 1) +fn gen_row_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x++) { + acc += textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +// The iris sum. data = spans (dy, left, right); aux = row prefix sums (when u[0].w). +// u[0] = (spans, repeat, linear, prefix); f[0].x = 1 / pixel count. +@compute @workgroup_size(16, 16) +fn gen_lens_blur(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let n = P.u[0].x; + let repeat = P.u[0].y != 0u; + let prefix = P.u[0].w != 0u; + let w = dims.x; + let h = dims.y; + var acc = vec4(0.0); + for (var s = 0u; s < n; s++) { + var yy = p.y + i32(data[3u * s]); + if (yy < 0 || yy >= h) { + if (!repeat) { + continue; + } + yy = clamp(yy, 0, h - 1); + } + var a = p.x + i32(data[3u * s + 1u]); + var bnd = p.x + i32(data[3u * s + 2u]); + if (!prefix) { + for (var i = a; i <= bnd; i++) { + if (repeat) { + acc += textureLoad(src, vec2(clamp(i, 0, w - 1), yy), 0); + } else if (i >= 0 && i < w) { + acc += textureLoad(src, vec2(i, yy), 0); + } + } + continue; + } + if (repeat) { + if (a < 0) { + let k = min(-a, bnd - a + 1); + acc += textureLoad(src, vec2(0, yy), 0) * f32(k); + a = 0; + } + if (bnd >= w) { + let k = min(bnd - (w - 1), bnd - a + 1); + acc += textureLoad(src, vec2(w - 1, yy), 0) * f32(k); + bnd = w - 1; + } + } else { + a = max(a, 0); + bnd = min(bnd, w - 1); + } + if (a <= bnd) { + acc += textureLoad(aux, vec2(bnd + 1, yy), 0) - textureLoad(aux, vec2(a, yy), 0); + } + } + var px = acc * P.f[0].x; + px.w = clamp(px.w, 0.0, 1.0); + if (P.u[0].z != 0u) { + let u = gen_unpremul(px); + px = vec4(linear_to_srgb(max(u.x, 0.0)) * u.w, linear_to_srgb(max(u.y, 0.0)) * u.w, linear_to_srgb(max(u.z, 0.0)) * u.w, u.w); + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- wipes + +// Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy) +// (hi, lo); u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y))); + let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as +// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w. +// u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let col = 6u * u32(p.x); + let row = gen_hl(P.u[0].x + 2u * u32(p.y)); + // (d mod w) wraps once the column and row terms reach w. + let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0; + var t: f32; + if (wrapped) { + t = gen_sum(gen_hl(col + 2u), row); + } else { + t = gen_sum(gen_hl(col), row); + } + let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, +// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe. +fn gen_psi(r: u32, x: u32, y: u32) -> f32 { + let c = 9u * x + 3u * r; + let w = P.u[0].x + 9u * y + 3u * r; + // x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R. + let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]); + let yr = data[w + 2u] + data[c + 2u]; + return atan2(xr, yr) * GEN_DEG; +} + +// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R), +// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe); +// f[0] = (edge, feather). +@compute @workgroup_size(16, 16) +fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = u32(p.x); + let y = u32(p.y); + let dir = P.u[0].y; + let edge = P.f[0].x; + var a = gen_psi(0u, x, y); + if (a < 0.0) { + a += 360.0; + } + var base: f32; + if (dir == 0u || (dir == 2u && a <= 180.0)) { + base = a - edge; + if (abs(base) < 90.0) { + base = gen_psi(1u, x, y); + } + } else { + base = (360.0 - a) - edge; + if (abs(base) < 90.0) { + base = -gen_psi(2u, x, y); + } + } + let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness). +@compute @workgroup_size(16, 16) +fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let done = P.f[0].x; + let soft = P.f[0].y; + let g = gen_unpremul(textureLoad(aux, p, 0)); + var t = clamp(luminance(g.xyz), 0.0, 1.0); + if (P.u[0].x != 0u) { + t = 1.0 - t; + } + var k = 0.0; + if (done >= 1.0) { + k = 0.0; + } else if (soft > 0.0) { + k = clamp((t - (done * (1.0 + soft) - soft)) / soft, 0.0, 1.0); + } else if (t >= done) { + k = 1.0; + } + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- Cell Pattern (generate2) + +// Worley distances for (u, v) in cell units: (F1, F2, hash of the nearest cell). +fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3 { + let tile = P.u[1].x != 0u; + let nx = i32(P.u[1].y); + let ny = i32(P.u[1].z); + let cx = i32(floor(u)); + let cy = i32(floor(v)); + var f1 = 3.0e38; + var f2 = 3.0e38; + var id = 0.0; + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + let gx = cx + i; + let gy = cy + j; + var tx = gx; + var ty = gy; + if (tile) { + tx = ((gx % nx) + nx) % nx; + ty = ((gy % ny) + ny) % ny; + } + let hx = bitcast(tx); + let hy = bitcast(ty); + let h1 = hash_noise(hx, hy, seed); + let h2 = hash_noise(hx, hy, seed + 1u); + let h3 = hash_noise(hx, hy, seed + 2u); + let ang = 6.283185307179586 * (h3 + evo); + let px = f32(gx) + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * cos(ang); + let py = f32(gy) + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * sin(ang); + let d = sqrt((px - u) * (px - u) + (py - v) * (py - v)); + if (d < f1) { + f2 = f1; + f1 = d; + id = hash_noise(hx, hy, seed + 3u); + } else if (d < f2) { + f2 = d; + } + } + } + return vec3(f1, f2, id); +} + +fn gen_cell_value(pattern: u32, f1: f32, f2: f32, id: f32) -> f32 { + let e = f2 - f1; + switch pattern { + case 0u: { return clamp(1.0 - f1 * 1.3, 0.0, 1.0); } + case 1u: { return id * 0.8 + 0.2 * clamp(1.0 - f1, 0.0, 1.0); } + case 2u: { return clamp(e * 2.0, 0.0, 1.0); } + case 3u: { return id * gen_smoothstep(0.0, 0.06, e); } + case 4u: { return id; } + case 5u: { return clamp(1.0 - f1 * f1 * 2.0, 0.0, 1.0); } + case 6u: { return id * clamp(e * 3.0, 0.0, 1.0); } + default: { return 1.0 - clamp(abs(e - 0.15) * 5.0, 0.0, 1.0); } + } +} + +fn gen_overflow(v: f32, mode: u32) -> f32 { + if (mode == 1u) { + return 0.5 + 0.5 * tanh((v - 0.5) * 2.0); + } + if (mode == 2u) { + let t = v - 2.0 * floor(v * 0.5); + return select(t, 2.0 - t, t > 1.0); + } + return clamp(v, 0.0, 1.0); +} + +// u[0] = (pattern kind, overflow, invert, seed); u[1] = (tile, columns, rows); +// f[0] = (contrast, disperse, size, evolution mod 1); f[1] = (0.5 − offset x, 0.5 − offset y). +@compute @workgroup_size(16, 16) +fn gen_cell_pattern(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let size = P.f[0].z; + let u = (f32(p.x) + P.f[1].x) / size; + let v = (f32(p.y) + P.f[1].y) / size; + let w = gen_worley(u, v, P.f[0].y, P.f[0].w, P.u[0].w); + var val = gen_cell_value(P.u[0].x, w.x, w.y, w.z); + val = gen_overflow((val - 0.5) * P.f[0].x + 0.5, P.u[0].y); + if (P.u[0].z != 0u) { + val = 1.0 - val; + } + textureStore(out, p, vec4(val, val, val, 1.0)); +} + +// ---------------------------------------------------------------- Checkerboard / Grid / 4-Color + +// Signed distance (in feather units) to the nearest cell edge, positive in even cells. +fn gen_axis(q: f32, size: f32, f: f32) -> f32 { + let u = q / size; + let fl = floor(u); + let fr = u - fl; + let d = min(fr, 1.0 - fr) * size; + let n = i32(fl); + let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0); + return s * min(d / (f * 0.5), 1.0); +} + +// u[0].x = mode; f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_checker(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let sx = gen_axis(f32(p.x) + P.f[0].x, P.f[0].z, P.f[1].x); + let sy = gen_axis(f32(p.y) + P.f[0].y, P.f[0].w, P.f[1].y); + let cov = clamp(0.5 + 0.5 * sx * sy, 0.0, 1.0) * P.f[1].z; + let c = P.f[2].xyz; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * cov, cov), P.u[0].x)); +} + +// f32::rem_euclid. +fn gen_rem(x: f32, m: f32) -> f32 { + let r = x - m * trunc(x / m); + return select(r, r + m, r < 0.0); +} + +// u[0] = (mode, invert); f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity, border); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_grid(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let w = P.f[0].z; + let h = P.f[0].w; + let border = P.f[1].w; + let gx = gen_rem(f32(p.x) + P.f[0].x, w); + let gy = gen_rem(f32(p.y) + P.f[0].y, h); + let dx = min(gx, w - gx); + let dy = min(gy, h - gy); + let cx = clamp((border / 2.0 - dx) / P.f[1].x + 0.5, 0.0, 1.0); + let cy = clamp((border / 2.0 - dy) / P.f[1].y + 0.5, 0.0, 1.0); + var cov = 0.0; + if (border > 0.0) { + cov = max(cx, cy); + } + if (P.u[0].y != 0u) { + cov = 1.0 - cov; + } + let a = cov * P.f[1].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(P.f[2].xyz * a, a), P.u[0].x)); +} + +// u[0] = (mode, seed); f[0..4] = points (x, y); f[4..8] = colours; +// f[8] = (blend · 100, jitter, opacity). +@compute @workgroup_size(16, 16) +fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let bl = P.f[8].x; + var acc = vec3(0.0); + var wsum = 0.0; + for (var i = 0; i < 4; i++) { + let q = P.f[i].xy; + let dx = f32(p.x) + 0.5 - q.x; + let dy = f32(p.y) + 0.5 - q.y; + let d2 = dx * dx + dy * dy; + let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + acc += P.f[4 + i].xyz * w; + wsum += w; + } + var c = acc / wsum; + let jitter = P.f[8].y; + if (jitter > 0.0) { + let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0); + c += vec3(n); + } + let op = P.f[8].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * op, op), P.u[0].x)); +} + +// ---------------------------------------------------------------- Noise / Add Grain + +// Noise (misc::noise). u[0] = (frame seed, colour, clip, width); f[0].x = amount. +@compute @workgroup_size(16, 16) +fn gen_noise(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let seed = P.u[0].x; + let w = P.u[0].w; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var n = vec3((hash_noise(x, y, seed) - 0.5) * amt); + if (P.u[0].y != 0u) { + n.y = (hash_noise(x + w, y, seed) - 0.5) * amt; + n.z = (hash_noise(x + 2u * w, y, seed) - 0.5) * amt; + } + if (P.u[0].z != 0u) { + px = vec4(clamp(px.xyz + n * a, vec3(0.0), vec3(a)), a); + } else { + // Unclipped: values past black / white wrap around. + px = vec4(fract_euclid(px.x / a + n.x) * a, fract_euclid(px.y / a + n.y) * a, fract_euclid(px.z / a + n.z) * a, a); + } + textureStore(out, p, px); +} + +// Add Grain's noise planes (GrainLook::planes before Softness): rgb = per-channel zero-mean +// value noise. u[0] = (mono, seed, salt); f[0].xyz = x divisors (size · aspect), f[0].w = frame; +// f[1].xyz = y divisors (size). +@compute @workgroup_size(16, 16) +fn gen_grain_planes(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = f32(p.x); + let y = f32(p.y); + let frame = P.f[0].w; + let seed = P.u[0].y; + let salt = P.u[0].z; + let n0 = value_noise(x / P.f[0].x, y / P.f[1].x, frame, seed) - 0.5; + var g = vec3(n0); + if (P.u[0].x == 0u) { + g.y = value_noise(x / P.f[0].y, y / P.f[1].y, frame, seed + salt) - 0.5; + g.z = value_noise(x / P.f[0].z, y / P.f[1].z, frame, seed + 2u * salt) - 0.5; + } + textureStore(out, p, vec4(g, 0.0)); +} + +// GrainLook::blend. +fn gen_grain_blend(mode: u32, c: f32, n: f32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c * (1.0 + n); } + case 2u: { v = c + n; } + case 3u: { v = 1.0 - (1.0 - c) * (1.0 - abs(n)); } + case 4u: { v = c + n * 4.0 * c * (1.0 - c); } + default: { v = c + n * (1.0 - 0.5 * c); } + } + return max(v, 0.0); +} + +// Add Grain applied: aux = noise planes. u[0] = (mono, mode, tint); f[0] = (amp, saturation, +// midpoint); f[1].xyz = tint factors; f[2].xyz = channel intensities; f[3].xyz = tone weights. +@compute @workgroup_size(16, 16) +fn gen_grain_apply(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let u = gen_unpremul(px); + let a = u.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = u.xyz; + var g = textureLoad(aux, p, 0).xyz; + if (P.u[0].x != 0u) { + g = vec3(g.x); + } else { + let gm = (g.x + g.y + g.z) / 3.0; + g = gm + (g - gm) * P.f[0].y; + } + if (P.u[0].z != 0u) { + g = g * P.f[1].xyz; + } + let mid = P.f[0].z; + let l = clamp(luminance(c), 0.0, 1.0); + let s = 1.0 - gen_smoothstep(0.0, mid, l); + let hi = gen_smoothstep(mid, 1.0, l); + let tones = P.f[3].xyz; + let weight = tones.x * s + tones.y * (1.0 - s - hi) + tones.z * hi; + let amp = P.f[0].x; + let ci = P.f[2].xyz; + let mode = P.u[0].y; + let o = vec3( + gen_grain_blend(mode, c.x, g.x * amp * ci.x * weight), + gen_grain_blend(mode, c.y, g.y * amp * ci.y * weight), + gen_grain_blend(mode, c.z, g.z * amp * ci.z * weight), + ); + textureStore(out, p, vec4(o * a, a)); +} diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs new file mode 100644 index 000000000..e15abb37e --- /dev/null +++ b/crates/gpu/src/tests_fx_generate.rs @@ -0,0 +1,200 @@ +//! GPU vs CPU for the blur, transition and generate family (`fx_generate`). + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn b(v: bool) -> Value { + Value::Bool(v) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn radial_blur() { + effect_case("ec.blur.radial", &[("amount", n(12.0))]); + effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]); + effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]); +} + +#[test] +fn cc_radial_fast_blur() { + effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]); +} + +#[test] +fn camera_lens_blur() { + effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(7.5)), + ("irisProperties/irisShape", e(2)), + ("irisProperties/irisRotation", n(20.0)), + ("irisProperties/irisAspectRatio", n(1.6)), + ("repeatEdge", b(true)), + ], + ); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(4.0)), + ("irisProperties/irisShape", e(8)), + ("highlight/specularBrightness", n(60.0)), + ("highlight/specularThreshold", n(150.0)), + ("highlight/highlightSaturation", n(40.0)), + ("useLinear", b(true)), + ], + ); + // Large iris: per-row prefix sums. + effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]); + effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]); +} + +#[test] +fn wipes() { + effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]); + effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]); + effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]); + effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]); + effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); + effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); +} + +/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU). +#[test] +fn gradient_wipe_with_a_gradient_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for placement in 0..3 { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let grad = s.footage(40, 30); + let gid = s.push(grad); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.transition.gradientwipe", + &[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("gradient wipe layer {placement} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn generators() { + effect_case("ec.generate.cellpattern", &[("size", n(14.0))]); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]); + effect_case( + "ec.generate.cellpattern", + &[ + ("cellPattern", e(11)), + ("size", n(9.0)), + ("tilingOptions/enableTiling", b(true)), + ("tilingOptions/cellsHorizontal", n(4.0)), + ("tilingOptions/cellsVertical", n(3.0)), + ("overflow", e(2)), + ("contrast", n(250.0)), + ("invert", b(true)), + ("evolutionOptions/randomSeed", n(17.0)), + ], + ); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]); + effect_case("ec.generate.checkerboard", &[("width", n(9.0))]); + effect_case( + "ec.generate.checkerboard", + &[ + ("sizeFrom", e(2)), + ("width", n(13.0)), + ("height", n(7.0)), + ("feather/featherWidth", n(4.0)), + ("anchor", pt(5.3, 2.2)), + ("blendingMode", e(5)), + ("opacity", n(70.0)), + ], + ); + effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + effect_case( + "ec.generate.grid", + &[ + ("sizeFrom", e(2)), + ("width", n(15.0)), + ("height", n(9.0)), + ("border", n(2.5)), + ("feather/featherWidth", n(3.0)), + ("feather/featherHeight", n(2.0)), + ("invertGrid", b(true)), + ("blendingMode", e(4)), + ], + ); + effect_case( + "ec.generate.grid", + &[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))], + ); + effect_case("ec.generate.fourcolor", &[]); + effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); + effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); +} + +#[test] +fn noise_and_grain() { + effect_case("ec.noise.noise", &[("amount", n(30.0))]); + effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]); + effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]); + effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(3.0)), + ("tweaking/size", n(2.5)), + ("tweaking/softness", n(1.5)), + ("tweaking/aspectRatio", n(1.4)), + ("tweaking/channelSize/redSize", n(0.6)), + ("color/saturation", n(0.5)), + ("color/tintAmount", n(0.4)), + ("color/tintColor", c(1.0, 0.6, 0.2)), + ("application/blendingMode", e(4)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(1.5)), + ("color/monochromatic", b(true)), + ("application/blendingMode", e(3)), + ("application/shadows", n(0.3)), + ("application/highlights", n(1.8)), + ("application/midpoint", n(0.35)), + ("animation/randomSeed", n(42.0)), + ("animation/animationSpeed", n(2.0)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 75d32478b..a69f91b67 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 303 effects with 2434 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 24 effects also run on the GPU compositor with identical results. +- **GPU**: 37 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 261 implemented in full, 42 partial (what is missing is listed). @@ -49,16 +49,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented | | CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented | | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | -| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented | +| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | -| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | -| Radial Blur | `ec.blur.radial` | 4 | | 32 | Implemented | +| Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | | Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented | | Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented | | Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented | @@ -187,7 +187,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented | +| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented | | Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | @@ -197,15 +197,15 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones | -| Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented | +| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones | +| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | | Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | -| Grid | `ec.generate.grid` | 12 | | 32 | Implemented | +| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | @@ -263,14 +263,14 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | -| Noise | `ec.noise.noise` | 3 | | 32 | Implemented | +| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | @@ -401,11 +401,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | -| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented | +| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | -| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented | -| Radial Wipe | `ec.transition.radialwipe` | 5 | | 32 | Implemented | -| Venetian Blinds | `ec.transition.venetian` | 4 | | 32 | Implemented | +| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | +| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented | +| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented | ## Utility From 91ab3a3fc56feb1943e70970043658ff08839ca9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:46:52 -0400 Subject: [PATCH 09/20] M12.7: GPU colour effects (Color Balance, Vibrance, Lumetri, Black & White, Tritone, Colorama, Channel Mixer, Selective Color, Linear Color Key, Key Light) fx_color kernels mirror the CPU effects operation for operation: one per-pixel pass for the point effects, Lumetri with its Sharpen luminance blur, and Key Light's matte pipeline (pre-blur, clip, rollback, despot, shrink/grow, softness, edge band) as plane passes. CPU fallback: Key Light with Inside/Outside masks or Hard Colour after Screen Softness, Colorama with Interpolate Palette off. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 11 + crates/gpu/src/fx_color.rs | 390 ++++++++++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/fx_color.wgsl | 705 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_color.rs | 163 +++++++ docs/effects.md | 22 +- 6 files changed, 1276 insertions(+), 17 deletions(-) create mode 100644 crates/gpu/src/tests_fx_color.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f3465..b5a1c9d22 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -641,6 +641,17 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + // effectcraft-gpu fx_color + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index 8205f0423..824f9a501 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -1,18 +1,396 @@ //! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Colour Balance, Vibrance, Black & White, Tritone, Colorama, Channel Mixer, Selective Color +//! and Linear Color Key are one per-pixel pass (`fxc_point`); Lumetri Color is a per-pixel pass +//! plus, with Sharpen, a luminance plane blurred like `util::gauss_plane`; Key Light runs its +//! matte pipeline (pre-blur, clip, rollback, despot, shrink / grow, softness, edge band) as +//! plane passes. Key Light with an Inside / Outside mask, or Hard Colour replacement after +//! Screen Softness, and Colorama with Interpolate Palette off run on the CPU. +use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_effects::EffectCtx; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_color.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "fxc_point", + "fxc_lumetri", + "fxc_luma", + "fxc_sharpen", + "fxc_morph", + "fxc_lerp", + "fxc_kl_raw", + "fxc_kl_rollback", + "fxc_kl_pack", + "fxc_kl_band", + "fxc_kl_edge", + "fxc_kl_final", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", +]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. -pub(crate) fn apply(_e: &mut Enc, _id: &str, _ctx: &EffectCtx, _b: GBuf) -> Option { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.color.lumetri" => lumetri(e, ctx, b), + "ec.keying.keylight" => key_light(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Same selective colour ids as `color2::SC_IDS`. +const SC_IDS: [[&str; 4]; 9] = [ + ["redsCyan", "redsMagenta", "redsYellow", "redsBlack"], + ["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"], + ["greensCyan", "greensMagenta", "greensYellow", "greensBlack"], + ["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"], + ["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"], + ["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"], + ["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"], + ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], + ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], +]; + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.colorbalance" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [1, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [g("shadowRed"), g("shadowGreen"), g("shadowBlue"), 0.0]; + p.f[1] = [g("midRed"), g("midGreen"), g("midBlue"), 0.0]; + p.f[2] = [g("hiRed"), g("hiGreen"), g("hiBlue"), 0.0]; + } + "ec.color.vibrance" => { + p.u[0][0] = 2; + p.f[0] = [f("vibrance") / 100.0, f("saturation") / 100.0, 0.0, 0.0]; + } + "ec.color.blackwhite" => { + let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); + let tc = pr.color("tintColor"); + let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); + p.u[0] = [3, pr.b("tint") as u32, 0, 0]; + p.f[0] = [w[0], w[1], w[2], w[3]]; + p.f[1] = [w[4], w[5], 0.0, 0.0]; + p.f[2] = [th, ts, 0.0, 0.0]; + } + "ec.color.tritone" => { + p.u[0][0] = 4; + p.f[0] = rgb(pr.color("highlights")); + p.f[1] = rgb(pr.color("midtones")); + p.f[2] = rgb(pr.color("shadows")); + p.f[3][0] = 1.0 - f("blend") / 100.0; + } + "ec.color.colorama" => { + let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); + // The posterized palette is discontinuous: phases on a step (common with 8 bpc + // input) can round to the other side in the GPU's float maths, so it runs on the CPU. + if !smooth { + return None; + } + p.u[0] = [5, pr.e("inputPhase/mode"), smooth as u32, 0]; + p.f[0] = [pr.f("outputCycle/cycles").max(0.0) as f32, f("inputPhase/phaseShift") / 360.0, 1.0 - f("blend") / 100.0, 0.0]; + } + "ec.color.channelmixer" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [6, pr.b("monochrome") as u32, 0, 0]; + p.f[0] = [g("rr"), g("rg"), g("rb"), g("rc")]; + p.f[1] = [g("gr"), g("gg"), g("gb"), g("gc")]; + p.f[2] = [g("br"), g("bg"), g("bb"), g("bc")]; + } + "ec.color.selectivecolor" => { + let adj: Vec = SC_IDS.iter().flat_map(|ids| ids.map(|k| f(k) / 100.0)).collect(); + if adj.iter().all(|v| *v == 0.0) { + return Some(b); + } + p.u[0] = [7, (pr.e("method") == 0) as u32, 0, 0]; + data = Some(e.data(&adj)); + } + "ec.key.linearcolor" => { + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let k = pr.color("keyColor"); + let s = k[0] + k[1] + k[2]; + let kc = if s <= 1e-6 { [1.0 / 3.0, 1.0 / 3.0] } else { [k[0] / s, k[1] / s] }; + let (kh, _, _) = rgb_to_hsl(k[0], k[1], k[2]); + p.u[0] = [8, pr.e("matchColors"), (pr.e("keyOperation") == 1) as u32, view]; + p.f[0] = [k[0], k[1], k[2], f("tolerance") / 100.0]; + p.f[1] = [f("softness") / 100.0, kc[0], kc[1], kh]; + } + _ => return None, + } + let out = e.image(b.img.width, b.img.height); + e.pixels("fxc_point", &p, &b.img, None, &out, data.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::tint_offset. +fn tint_offset(c: [f32; 4]) -> [f32; 3] { + let l = (c[0] + c[1] + c[2]) / 3.0; + [c[0] - l, c[1] - l, c[2] - l] +} + +fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| (pr.f(k) / 100.0) as f32; + let mut d: Vec = vec![ + f("basicCorrection/whiteBalance/temperature"), + f("basicCorrection/whiteBalance/tint"), + 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32), + f("basicCorrection/tone/contrast"), + f("basicCorrection/tone/highlights"), + f("basicCorrection/tone/shadows"), + f("basicCorrection/tone/whites"), + f("basicCorrection/tone/blacks"), + f("basicCorrection/saturation"), + f("creative/lookIntensity"), + f("creative/adjustments/fadedFilm"), + f("creative/adjustments/vibrance"), + f("creative/adjustments/creativeSaturation"), + (0.5 + 0.25 * f("creative/adjustments/tintBalance")).clamp(0.05, 0.95), + ]; + d.extend(tint_offset(pr.color("creative/adjustments/shadowTint"))); + d.extend(tint_offset(pr.color("creative/adjustments/highlightTint"))); + for c in ["Master", "Red", "Green", "Blue"] { + for part in ["Shadows", "Midtones", "Highlights"] { + d.push(f(&format!("curves/curve{c}{part}"))); + } + } + for w in ["shadowsWheel", "midtonesWheel", "highlightsWheel"] { + d.extend(tint_offset(pr.color(&format!("colorWheels/{w}")))); + } + let (lx, ly) = (b.offset[0], b.offset[1]); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + d.extend([ + pr.f("vignette/vignetteAmount") as f32, + f("vignette/vignetteMidpoint") * 0.9, + f("vignette/vignetteRoundness"), + f("vignette/vignetteFeather"), + (lx + lw * 0.5) as f32, + (ly + lh * 0.5) as f32, + lw as f32, + lh as f32, + (lw / lh.max(1e-6)) as f32, + ]); + debug_assert_eq!(d.len(), 50); + let buf = e.data(&d); + let out = e.image(b.img.width, b.img.height); + e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf)); + let sharpen = f("creative/adjustments/sharpen"); + if sharpen == 0.0 { + return Some(GBuf { img: out, ..b }); + } + let luma = e.image(out.width, out.height); + e.pixels("fxc_luma", &Params::default(), &out, None, &luma, None); + let blurred = gaussian_blur(e, &luma, b.scale, b.scale, true); + let mut p = Params::default(); + p.f[0][0] = sharpen * 2.0; + let sharp = e.image(out.width, out.height); + e.pixels("fxc_sharpen", &p, &out, Some(&blurred), &sharp, None); + Some(GBuf { img: sharp, ..b }) +} + +// ---------------------------------------------------------------- planes + +/// util::morph_plane with the same radius on both axes; `modes` = per-channel operation +/// (0 erode, 1 dilate, 2 keep). +fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { + if r == 0 { + return img.clone(); + } + let mut cur = img.clone(); + for vertical in [0, 1] { + let mut p = Params::default(); + p.u[0] = [r, vertical, 0, 0]; + p.u[1] = modes; + let out = e.image(cur.width, cur.height); + e.pixels("fxc_morph", &p, &cur, None, &out, None); + cur = out; + } + cur +} + +/// util::morph_frac (blend between the two nearest integer radii). +fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { + if radius <= 0.0 { + return img.clone(); + } + let r0 = radius.floor() as u32; + let t = (radius - r0 as f64) as f32; + let a = morph(e, img, r0, modes); + if t < 1e-4 { + return a; + } + let b = morph(e, img, r0 + 1, modes); + let mut p = Params::default(); + p.f[0][0] = t; + let out = e.image(img.width, img.height); + e.pixels("fxc_lerp", &p, &a, Some(&b), &out, None); + out +} + +const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +const DILATE_X: [u32; 4] = [1, 2, 2, 2]; + +// ---------------------------------------------------------------- Key Light + +/// keylight::primary. +fn primary(s: [f32; 3]) -> [u32; 3] { + let pi: usize = if s[1] >= s[0] && s[1] >= s[2] { + 1 + } else if s[2] >= s[0] { + 2 + } else { + 0 + }; + let (a, b) = match pi { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let ix = if s[a] >= s[b] { [pi, a, b] } else { [pi, b, a] }; + ix.map(|i| i as u32) +} + +fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] { + let l = luminance(bias[0], bias[1], bias[2]).max(1e-4); + [0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4)) +} + +fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + // Inside / Outside masks (polygon coverage) run on the CPU. + let has_mask = |id: &str| pr.e(id).checked_sub(1).is_some_and(|i| ctx.env.masks.get(i as usize).is_some()); + if has_mask("insideMask/insideMask") || has_mask("outsideMask/outsideMask") { + return None; + } + let sc = pr.color("screenColour"); + let screen = [sc[0], sc[1], sc[2]]; + let ix = primary(screen); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let dbias = pr.color("despillBias"); + let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") }; + let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]); + let s_alpha = neutralise(screen, abias); + let sd = (s_alpha[pi] - (bal * s_alpha[o1] + (1.0 - bal) * s_alpha[o2])).max(1e-3); + let cb = pr.f("screenMatte/clipBlack") as f32 / 100.0; + let cw = pr.f("screenMatte/clipWhite") as f32 / 100.0; + let rollback = pr.f("screenMatte/clipRollback") * b.scale; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenMatte/screenShrinkGrow") * b.scale; + let soft = pr.f("screenMatte/screenSoftness") * b.scale; + let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale; + let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale; + // Hard Colour replaces wherever the screen matte exceeds the raw matte by any amount; after + // Screen Softness's blur that compares float-rounded sums, so it runs on the CPU. + if pr.e("screenMatte/replaceMethod") == 2 && soft > 0.0 { + return None; + } + let (w, h) = (b.img.width, b.img.height); + + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [abias[0], abias[1], abias[2], sd]; + p.f[1] = [gain, bal, cb, cw]; + let raw = e.image(w, h); + e.pixels("fxc_kl_raw", &p, &key_src, None, &raw, None); + let mut m = raw.clone(); + if rollback > 0.0 { + let sg_img = morph_frac(e, &m, rollback, [0, 1, 2, 2]); + let out = e.image(w, h); + e.pixels("fxc_kl_rollback", &Params::default(), &sg_img, Some(&raw), &out, None); + m = out; + } + if despot_b > 0.0 { + let d = morph_frac(e, &m, despot_b, DILATE_X); + m = morph_frac(e, &d, despot_b, ERODE_X); + } + if despot_w > 0.0 { + let d = morph_frac(e, &m, despot_w, ERODE_X); + m = morph_frac(e, &d, despot_w, DILATE_X); + } + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut planes = e.image(w, h); + e.pixels("fxc_kl_pack", &Params::default(), &m, Some(&raw), &planes, None); + let src_alpha_mode = pr.e("insideMask/sourceAlpha"); + if pr.b("edgeColourCorrection/enableEdgeColourCorrection") { + let grow = pr.f("edgeColourCorrection/edgeGrow") * b.scale; + let hard = (pr.f("edgeColourCorrection/edgeHardness") / 100.0) as f32; + let esoft = pr.f("edgeColourCorrection/edgeSoftness") * b.scale; + let mut p = Params::default(); + p.u[0][0] = src_alpha_mode; + let mut band = e.image(w, h); + e.pixels("fxc_kl_band", &p, &b.img, Some(&planes), &band, None); + if grow > 0.0 { + band = morph_frac(e, &band, grow, DILATE_X); + } + if esoft > 0.0 { + band = gaussian_blur(e, &band, esoft * 0.5, esoft * 0.5, true); + } + let mut p = Params::default(); + p.f[0][0] = hard; + let out = e.image(w, h); + e.pixels("fxc_kl_edge", &p, &planes, Some(&band), &out, None); + planes = out; + } + let pc = |k: &str| pr.f(k) as f32 / 100.0; + let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4); + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], view]; + p.u[1] = [ + pr.e("screenMatte/replaceMethod"), + pr.b("foregroundColourCorrection/enableColourCorrection") as u32, + pr.b("unpremultiplyResult") as u32, + src_alpha_mode, + ]; + p.f[0] = [screen[0], screen[1], screen[2], bal]; + p.f[1] = [dbias[0], dbias[1], dbias[2], l0]; + p.f[2] = pr.color("screenMatte/replaceColour"); + p.f[3] = + [pc("foregroundColourCorrection/saturation"), pc("foregroundColourCorrection/contrast") + 1.0, pc("foregroundColourCorrection/brightness") + 1.0, 0.0]; + p.f[4] = [pc("edgeColourCorrection/edgeSaturation"), pc("edgeColourCorrection/edgeContrast") + 1.0, pc("edgeColourCorrection/edgeBrightness") + 1.0, 0.0]; + p.f[5] = ["sourceCrops/cropLeft", "sourceCrops/cropRight", "sourceCrops/cropTop", "sourceCrops/cropBottom"].map(|k| (pr.f(k) / 100.0) as f32); + p.f[6] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, 0.0]; + p.f[7] = [ctx.layer_size[0].max(1e-9) as f32, ctx.layer_size[1].max(1e-9) as f32, 0.0, 0.0]; + let out = e.image(w, h); + e.pixels("fxc_kl_final", &p, &b.img, Some(&planes), &out, None); + Some(GBuf { img: out, ..b }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9ebce2090..ce8724601 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -149,3 +149,5 @@ mod tests_3d; mod tests_adjust; #[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_color; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 3e42688d2..97771a46d 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -1,2 +1,707 @@ // GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Every name here is prefixed `fxc_` (the family files share one module). + +// ---------------------------------------------------------------- helpers + +// util::unpremul: straight colour (0 when alpha ≤ 1e-6). +fn fxc_unpremul(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +// util::smoothstep. +fn fxc_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +fn fxc_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// color::hsv_to_rgb. +fn fxc_hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + let h6 = fract_euclid(h) * 6.0; + let i = floor(h6); + let f = h6 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + switch i32(i) { + case 0: { return vec3(v, t, p); } + case 1: { return vec3(q, v, p); } + case 2: { return vec3(p, v, t); } + case 3: { return vec3(p, q, v); } + case 4: { return vec3(t, p, v); } + default: { return vec3(v, p, q); } + } +} + +fn fxc_pixel(gid: vec3) -> vec2 { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return vec2(-1); + } + return p; +} + +// ---------------------------------------------------------------- per-pixel colour effects + +// Colorama's input phase (color_fx::colorama_phase). +fn fxc_colorama_phase(c: vec3, a: f32, mode: u32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = rgb_to_hsl(c).x; } + case 5u: { v = rgb_to_hsl(c).z; } + case 6u: { v = rgb_to_hsl(c).y; } + case 7u: { v = max(max(c.x, c.y), c.z); } + case 8u: { v = a; } + case 9u: { v = 0.0; } + default: { v = luminance(c); } + } + return clamp(v, 0.0, 1.0); +} + +// Selective Color range weights (color2::sc_weights). +fn fxc_sc_weights(c0: vec3) -> array { + let c = clamp(c0, vec3(0.0), vec3(1.0)); + // Stable descending sort of the channel indices. + var idx = vec3(0u, 1u, 2u); + for (var i = 1u; i < 3u; i++) { + var j = i; + while (j > 0u && c[idx[j]] > c[idx[j - 1u]]) { + let t = idx[j]; + idx[j] = idx[j - 1u]; + idx[j - 1u] = t; + j--; + } + } + let mx = c[idx.x]; + let md = c[idx.y]; + let mn = c[idx.z]; + var w = array(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + var prim = array(0u, 2u, 4u); + var sec = array(3u, 5u, 1u); + w[prim[idx.x]] = mx - md; + w[sec[idx.z]] = md - mn; + w[6] = max((mn - 0.5) * 2.0, 0.0); + w[8] = max((0.5 - mx) * 2.0, 0.0); + w[7] = clamp(1.0 - (abs(mx - 0.5) + abs(mn - 0.5)), 0.0, 1.0); + return w; +} + +// u[0].x = op: +// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity +// 2 Vibrance f[0] = (vibrance, saturation) +// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); +// u[0].y = tint; f[2] = (tint hue, tint saturation) +// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend +// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) +// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome +// 7 Selective Color data = 9 × (cyan, magenta, yellow, black); u[0].y = relative +// 8 Linear Color Key f[0] = (key rgb, tolerance), f[1] = (softness, key chroma xy, key hue); +// u[0] = (_, match mode, keep colours, view) +@compute @workgroup_size(16, 16) +fn fxc_point(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + switch P.u[0].x { + case 1u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + let ws = clamp(1.0 - l * 2.0, 0.0, 1.0); + let wh = clamp(l * 2.0 - 1.0, 0.0, 1.0); + let wm = 1.0 - ws - wh; + var o = max(c + (P.f[0].xyz * ws + P.f[1].xyz * wm + P.f[2].xyz * wh) * 0.5, vec3(0.0)); + if (P.u[0].y != 0u) { + let d = luminance(c) - luminance(o); + o = max(o + d, vec3(0.0)); + } + px = vec4(o * a, a); + } + } + case 2u: { + if (a > 0.0) { + let c = px.xyz / a; + let hsl = rgb_to_hsl(c); + let vib = P.f[0].x; + let sat = P.f[0].y; + let s = clamp(hsl.y * (1.0 + sat) + vib * (1.0 - hsl.y) * min(hsl.y, 0.5), 0.0, 1.0); + px = vec4(hsl_to_rgb(hsl.x, s, hsl.z) * a, a); + } + } + case 3u: { + if (a > 0.0) { + let c = px.xyz / a; + var w = array(P.f[0].x, P.f[0].y, P.f[0].z, P.f[0].w, P.f[1].x, P.f[1].y); + let hsl = rgb_to_hsl(c); + let hp = hsl.x * 6.0; + let i = u32(max(floor(hp), 0.0)) % 6u; + let f = hp - floor(hp); + let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f; + let base = luminance(c); + let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0); + var o = vec3(gray); + if (P.u[0].y != 0u) { + o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + } + px = vec4(o * a, a); + } + } + case 4u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + var t: vec3; + if (l < 0.5) { + t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); + } else { + t = fxc_mix(P.f[1].xyz, P.f[0].xyz, (l - 0.5) * 2.0); + } + px = vec4(fxc_mix(c, t, P.f[3].x) * a, a); + } + } + case 5u: { + if (a > 0.0) { + let c = px.xyz / a; + var ph = fract_euclid(fxc_colorama_phase(c, a, P.u[0].y) * P.f[0].x + P.f[0].y); + if (P.u[0].z == 0u) { + ph = floor(ph * 6.0) / 6.0; + } + let o = fxc_mix(c, fxc_hsv_to_rgb(ph, 1.0, 1.0), P.f[0].z); + px = vec4(o * a, a); + } + } + case 6u: { + if (a > 0.0) { + let c = px.xyz / a; + var o: vec3; + for (var i = 0; i < 3; i++) { + let r = P.f[i]; + o[i] = max(c.x * r.x + c.y * r.y + c.z * r.z + r.w, 0.0); + } + if (P.u[0].y != 0u) { + o = vec3(o.x); + } + px = vec4(o * a, a); + } + } + case 7u: { + // color2::map_ca: fully transparent black stays. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + var w = fxc_sc_weights(c); + var o = c; + let relative = P.u[0].y != 0u; + for (var r = 0u; r < 9u; r++) { + let wr = w[r]; + if (wr <= 0.0) { + continue; + } + let k = data[r * 4u + 3u]; + for (var i = 0u; i < 3u; i++) { + let ink = 1.0 - c[i]; + var delta = data[r * 4u + i] + k; + if (relative) { + delta = delta * max(ink, 0.0); + } + o[i] -= wr * delta; + } + } + let na = clamp(max(a, 0.0), 0.0, 1.0); + px = vec4(max(o, vec3(0.0)) * na, na); + } + case 8u: { + let c = fxc_unpremul(px); + let key = P.f[0].xyz; + let tol = P.f[0].w; + let soft = P.f[1].x; + var d: f32; + switch P.u[0].y { + case 1u: { + let hsl = rgb_to_hsl(c); + if (hsl.y < 0.02) { + d = 1.0; + } else { + let dh = abs(hsl.x - P.f[1].w); + d = min(dh, 1.0 - dh) * 2.0; + } + } + case 2u: { + let s = c.x + c.y + c.z; + var pc = vec2(1.0 / 3.0, 1.0 / 3.0); + if (s > 1e-6) { + pc = vec2(c.x / s, c.y / s); + } + let dx = pc.x - P.f[1].y; + let dy = pc.y - P.f[1].z; + d = sqrt(dx * dx + dy * dy) / 1.4142135; + } + default: { + let e = c - key; + d = sqrt(e.x * e.x + e.y * e.y + e.z * e.z) / 1.7320508; + } + } + var v: f32; + if (soft > 1e-6) { + v = clamp((d - tol) / soft, 0.0, 1.0); + } else if (d > tol) { + v = 1.0; + } else { + v = 0.0; + } + if (P.u[0].z != 0u) { + v = 1.0 - v; + } + let k = clamp(v, 0.0, 1.0); + if (P.u[0].w == 2u) { + let m = px.w * k; + px = vec4(m, m, m, 1.0); + } else { + px = px * k; + } + } + default: {} + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- Lumetri Color (color3) + +fn fxc_tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 { + if (s == 0.0 && m == 0.0 && h == 0.0) { + return x; + } + let t = clamp(x, 0.0, 1.0); + let d0 = (t - 0.25) / 0.25; + let d1 = (t - 0.5) / 0.25; + let d2 = (t - 0.75) / 0.25; + let b0 = max(1.0 - d0 * d0, 0.0); + let b1 = max(1.0 - d1 * d1, 0.0); + let b2 = max(1.0 - d2 * d2, 0.0); + return x + 0.25 * (s * (b0 * b0) + m * (b1 * b1) + h * (b2 * b2)); +} + +fn fxc_d3(i: u32) -> vec3 { + return vec3(data[i], data[i + 1u], data[i + 2u]); +} + +// data: 0 temperature, 1 tint, 2 exposure gain, 3 contrast, 4 highlights, 5 shadows, 6 whites, +// 7 blacks, 8 saturation, 9 look intensity, 10 faded film, 11 vibrance, 12 creative saturation, +// 13 tint balance pivot, 14..17 shadow tint, 17..20 highlight tint, 20..32 curves (master, red, +// green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights), +// 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh, +// 49 aspect. +@compute @workgroup_size(16, 16) +fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + var c = fxc_unpremul(px); + let temp = data[0]; + let tint = data[1]; + if (temp != 0.0 || tint != 0.0) { + c = vec3(c.x * (1.0 + 0.25 * temp), c.y * (1.0 - 0.25 * tint), c.z * (1.0 - 0.25 * temp)); + } + let expo = data[2]; + if (expo != 1.0) { + c = c * expo; + } + let contrast = data[3]; + if (contrast != 0.0) { + c = 0.5 + (c - 0.5) * (1.0 + contrast); + } + let hi = data[4]; + let sh = data[5]; + let wh = data[6]; + let bl = data[7]; + if (hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0) { + let l = max(luminance(c), 0.0); + let lt = min(l, 1.0); + let il = 1.0 - lt; + let d = 0.25 * hi * fxc_smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - fxc_smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (il * il); + c = c + d; + } + let l0 = luminance(c); + let sat = data[8]; + if (sat != 1.0) { + c = l0 + (c - l0) * sat; + } + let intensity = data[9]; + let faded = data[10]; + let vib = data[11]; + let csat = data[12]; + let st = fxc_d3(14u); + let ht = fxc_d3(17u); + let zero = vec3(0.0); + if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { + var d = c; + if (faded != 0.0) { + d = d * (1.0 - 0.25 * faded) + 0.12 * faded; + } + let l = luminance(d); + if (vib != 0.0) { + let mx = max(max(d.x, d.y), d.z); + let mn = min(min(d.x, d.y), d.z); + var s = 0.0; + if (mx > 1e-6) { + s = (mx - mn) / mx; + } + let k = 1.0 + vib * (1.0 - clamp(s, 0.0, 1.0)); + d = l + (d - l) * k; + } + if (csat != 1.0) { + d = l + (d - l) * csat; + } + let pivot = data[13]; + let lt = clamp(l, 0.0, 1.0); + let wsh = 1.0 - fxc_smoothstep(0.0, pivot, lt); + let whi = fxc_smoothstep(pivot, 1.0, lt); + d = d + 0.5 * (st * wsh + ht * whi); + c = c + (d - c) * intensity; + } + for (var i = 0u; i < 3u; i++) { + c[i] = fxc_tone_curve(c[i], data[20], data[21], data[22]); + } + for (var i = 0u; i < 3u; i++) { + let b = 23u + i * 3u; + c[i] = fxc_tone_curve(c[i], data[b], data[b + 1u], data[b + 2u]); + } + let ws = fxc_d3(32u); + let wm = fxc_d3(35u); + let wh3 = fxc_d3(38u); + if (any(ws != zero) || any(wm != zero) || any(wh3 != zero)) { + let lt = clamp(luminance(c), 0.0, 1.0); + let ka = 1.0 - fxc_smoothstep(0.0, 0.5, lt); + let kz = fxc_smoothstep(0.5, 1.0, lt); + let km = 1.0 - ka - kz; + c = c + 0.5 * (ws * ka + wm * km + wh3 * kz); + } + let vig = data[41]; + if (vig != 0.0) { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lw = data[47]; + let lh = data[48]; + var nx = (x - data[45]) / max(lw * 0.5, 1e-6); + var ny = (y - data[46]) / max(lh * 0.5, 1e-6); + let vround = data[43]; + let aspect = data[49]; + if (vround > 0.0) { + nx = nx * (1.0 + (max(aspect, 1.0) - 1.0) * vround); + ny = ny * (1.0 + (max(1.0 / aspect, 1.0) - 1.0) * vround); + } + let pw = 2.0 + max(-vround, 0.0) * 6.0; + let r = powz(powz(abs(nx), pw) + powz(abs(ny), pw), 1.0 / pw) / 1.4142135; + let start = data[42]; + let f = fxc_smoothstep(start, start + 0.05 + data[44] * (1.05 - start), r); + if (vig > 0.0) { + c = c + (1.0 - c) * min(vig * 0.2 * f, 1.0); + } else { + let k = 1.0 + vig * 0.2 * f; + c = c * max(k, 0.0); + } + } + c = max(c, zero); + textureStore(out, p, vec4(c * a, a)); +} + +// Straight-colour luminance plane (util::Plane::luma) in x. +@compute @workgroup_size(16, 16) +fn fxc_luma(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + textureStore(out, p, vec4(luminance(fxc_unpremul(px)), 0.0, 0.0, 0.0)); +} + +// Lumetri Sharpen: `src` + (luma − blurred luma `aux`.x) × amount. f[0].x = sharpen × 2. +@compute @workgroup_size(16, 16) +fn fxc_sharpen(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + let d = (luminance(c) - textureLoad(aux, p, 0).x) * P.f[0].x; + textureStore(out, p, vec4(max(c + d, vec3(0.0)) * a, a)); +} + +// ---------------------------------------------------------------- planes (util) + +// One morphology pass (util::minmax_row, edges repeated) of radius u[0].x along rows (u[0].y = 0) +// or columns; u[1] = per-channel operation: 0 min, 1 max, 2 copy. +@compute @workgroup_size(16, 16) +fn fxc_morph(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let dims = out_dims(); + let r = i32(P.u[0].x); + let vertical = P.u[0].y != 0u; + let modes = P.u[1]; + let centre = textureLoad(src, p, 0); + var lo = centre; + var hi = centre; + for (var k = -r; k <= r; k++) { + var q = p; + if (vertical) { + q.y = clamp(p.y + k, 0, dims.y - 1); + } else { + q.x = clamp(p.x + k, 0, dims.x - 1); + } + let v = textureLoad(src, q, 0); + lo = min(lo, v); + hi = max(hi, v); + } + var o = centre; + for (var i = 0; i < 4; i++) { + if (modes[i] == 0u) { + o[i] = lo[i]; + } else if (modes[i] == 1u) { + o[i] = hi[i]; + } + } + textureStore(out, p, o); +} + +// `src` + (`aux` − `src`) × f[0].x (Plane::zip_map lerp). +@compute @workgroup_size(16, 16) +fn fxc_lerp(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let x = textureLoad(src, p, 0); + let y = textureLoad(aux, p, 0); + textureStore(out, p, x + (y - x) * P.f[0].x); +} + +// ---------------------------------------------------------------- Key Light (keylight.rs) + +fn fxc_neutralise(c: vec3, bias: vec3) -> vec3 { + let l = max(luminance(bias), 1e-4); + return vec3(c.x * l / max(bias.x, 1e-4), c.y * l / max(bias.y, 1e-4), c.z * l / max(bias.z, 1e-4)); +} + +fn fxc_screen_diff(c: vec3, ix: vec3, bal: f32) -> f32 { + return c[ix.x] - (bal * c[ix.y] + (1.0 - bal) * c[ix.z]); +} + +fn fxc_correct(c0: vec3, sat: f32, contrast: f32, bright: f32) -> vec3 { + let l = luminance(c0); + let c = l + (c0 - l) * sat; + return ((c - 0.5) * contrast + 0.5) * bright; +} + +// Raw screen matte of `src` (the key source): out = (clipped, clipped, raw, 0). +// u[0].xyz = primary, other channels; f[0] = (alpha bias, screen difference); +// f[1] = (gain, balance, clip black, clip white). +@compute @workgroup_size(16, 16) +fn fxc_kl_raw(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let c = fxc_unpremul(textureLoad(src, p, 0)); + let d = fxc_screen_diff(fxc_neutralise(c, P.f[0].xyz), P.u[0].xyz, P.f[1].y); + let raw = clamp(1.0 - P.f[1].x * d / P.f[0].w, 0.0, 1.0); + let cb = P.f[1].z; + let m = clamp((raw - cb) / max(P.f[1].w - cb, 1e-4), 0.0, 1.0); + textureStore(out, p, vec4(m, m, raw, 0.0)); +} + +// Clip Rollback: `src` = (shrunk, grown) clipped matte, `aux` = raw planes (clipped, _, raw). +@compute @workgroup_size(16, 16) +fn fxc_kl_rollback(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let sg = textureLoad(src, p, 0); + let r = textureLoad(aux, p, 0); + var m = r.x; + if (sg.y > sg.x + 1e-4) { + m = min(max(m, r.z), sg.y); + } + textureStore(out, p, vec4(m, m, r.z, 0.0)); +} + +// Planes for the final pass: (screen matte from `src`.x, raw from `aux`.z, edge 0, 0). +@compute @workgroup_size(16, 16) +fn fxc_kl_pack(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + textureStore(out, p, vec4(textureLoad(src, p, 0).x, textureLoad(aux, p, 0).z, 0.0, 0.0)); +} + +// Combined matte from the screen matte and the source alpha (no inside / outside masks). +fn fxc_kl_combined(sm: f32, src_a: f32, mode: u32) -> f32 { + var a = sm; + let sa = clamp(src_a, 0.0, 1.0); + var ins = 0.0; + if (mode == 1u) { + ins = max(ins, sa); + } + a = max(a, ins); + if (mode == 2u) { + a = a * sa; + } + return clamp(a, 0.0, 1.0); +} + +// Edge band of the combined matte: `src` = source, `aux` = planes; u[0].x = source alpha mode. +@compute @workgroup_size(16, 16) +fn fxc_kl_band(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let a = fxc_kl_combined(textureLoad(aux, p, 0).x, textureLoad(src, p, 0).w, P.u[0].x); + textureStore(out, p, vec4(select(0.0, 1.0, a > 1e-3 && a < 0.999), 0.0, 0.0, 0.0)); +} + +// Planes `src` with the edge weight from the processed band `aux`.x; f[0].x = hardness. +@compute @workgroup_size(16, 16) +fn fxc_kl_edge(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let pl = textureLoad(src, p, 0); + let e = min(textureLoad(aux, p, 0).x * (1.0 + P.f[0].x * 4.0), 1.0); + textureStore(out, p, vec4(pl.x, pl.y, e, 0.0)); +} + +fn fxc_kl_replace(fg: vec3, c: vec3, method: u32, rc: vec3, amt: f32) -> vec3 { + if (amt <= 0.0) { + return fg; + } + switch method { + case 1u: { return fg + (c - fg) * amt; } + case 2u: { return rc; } + case 3u: { + let l = luminance(c) / max(luminance(rc), 1e-4); + return fg + (rc * l - fg) * amt; + } + default: { return fg; } + } +} + +// Final pass: `src` = source, `aux` = planes (screen matte, raw, edge). +// u[0] = (primary, others, view); u[1] = (replace method, foreground cc, unpremultiply, +// source alpha mode); f[0] = (screen, balance); f[1] = (despill bias, l0); f[2] = replace colour; +// f[3] = fg cc (sat, contrast, bright); f[4] = edge cc; f[5] = crops (l, r, t, b); +// f[6] = (offset, scale, _); f[7] = (layer w, layer h). +@compute @workgroup_size(16, 16) +fn fxc_kl_final(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let s = textureLoad(src, p, 0); + let pl = textureLoad(aux, p, 0); + let ix = P.u[0].xyz; + let view = P.u[0].w; + let c = fxc_unpremul(s); + let sm = pl.x; + let raw = pl.y; + let edge = pl.z; + let a = fxc_kl_combined(sm, s.w, P.u[1].w); + let screen = P.f[0].xyz; + let bal = P.f[0].w; + let dbias = P.f[1].xyz; + let fg_cc = P.u[1].y != 0u; + let ar = max(raw, 1e-4); + var fg = max((c - (1.0 - ar) * screen) / ar, vec3(0.0)); + let spill = max(fxc_screen_diff(fxc_neutralise(fg, dbias), ix, bal), 0.0); + fg[ix.x] -= spill / P.f[1].w; + let intermediate = fg; + fg = fxc_kl_replace(fg, c, P.u[1].x, P.f[2].xyz, max(sm - raw, 0.0)); + if (fg_cc) { + fg = fxc_correct(fg, P.f[3].x, P.f[3].y, P.f[3].z); + } + if (edge > 0.0) { + let ce = fxc_correct(fg, P.f[4].x, P.f[4].y, P.f[4].z); + fg = fg + (ce - fg) * edge; + } + var o: vec4; + switch view { + case 1u: { o = vec4(s.w, s.w, s.w, 1.0); } + case 2u: { + var cs = fxc_neutralise(c, dbias); + let s2 = max(fxc_screen_diff(cs, ix, bal), 0.0); + cs[ix.x] -= s2; + if (fg_cc) { + cs = fxc_correct(cs, P.f[3].x, P.f[3].y, P.f[3].z); + } + o = vec4(cs, 1.0); + } + case 3u: { o = vec4(edge, edge, edge, 1.0); } + case 4u: { o = vec4(sm, sm, sm, 1.0); } + case 5u, 6u: { o = vec4(0.0, 0.0, 0.0, 1.0); } + case 7u: { o = vec4(a, a, a, 1.0); } + case 8u: { + var v = 0.5; + if (a <= 1e-3) { + v = 0.0; + } else if (a >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } + case 9u: { o = vec4(intermediate * sm, sm); } + default: { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lx = (x - P.f[6].x) / P.f[6].z / P.f[7].x; + let ly = (y - P.f[6].y) / P.f[6].z / P.f[7].y; + let cr = P.f[5]; + let cropped = lx < cr.x || lx > 1.0 - cr.y || ly < cr.z || ly > 1.0 - cr.w; + if (cropped) { + o = vec4(0.0); + } else if (P.u[1].z != 0u) { + o = vec4(fg * a, a); + } else { + o = vec4(fg * a * a, a); + } + } + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs new file mode 100644 index 000000000..8fb455b8a --- /dev/null +++ b/crates/gpu/src/tests_fx_color.rs @@ -0,0 +1,163 @@ +//! GPU colour-family effects vs the CPU effects (the oracle): direct on a buffer at full and +//! half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use crate::tests::{c, effect_case, n}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +#[test] +fn color_balance_vibrance_tritone_mixer() { + effect_case("ec.color.colorbalance", &[("shadowRed", n(40.0)), ("midGreen", n(-30.0)), ("hiBlue", n(60.0))]); + effect_case("ec.color.colorbalance", &[("shadowBlue", n(-50.0)), ("midRed", n(70.0)), ("hiGreen", n(25.0)), ("preserveLuminosity", on())]); + effect_case("ec.color.vibrance", &[("vibrance", n(60.0)), ("saturation", n(-20.0))]); + effect_case("ec.color.vibrance", &[("vibrance", n(-40.0)), ("saturation", n(35.0))]); + effect_case("ec.color.tritone", &[("midtones", c(0.6, 0.3, 0.5)), ("shadows", c(0.0, 0.1, 0.3)), ("blend", n(30.0))]); + effect_case("ec.color.tritone", &[("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.channelmixer", &[("rr", n(80.0)), ("rg", n(30.0)), ("gb", n(-40.0)), ("bc", n(10.0)), ("br", n(50.0))]); + effect_case("ec.color.channelmixer", &[("rr", n(40.0)), ("rg", n(40.0)), ("rb", n(20.0)), ("rc", n(-5.0)), ("monochrome", on())]); +} + +#[test] +fn black_white_colorama_selective_color() { + effect_case("ec.color.blackwhite", &[]); + effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]); + effect_case("ec.color.colorama", &[]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]); + effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]); + effect_case( + "ec.color.selectivecolor", + &[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))], + ); +} + +#[test] +fn lumetri() { + effect_case( + "ec.color.lumetri", + &[ + ("basicCorrection/whiteBalance/temperature", n(30.0)), + ("basicCorrection/whiteBalance/tint", n(-20.0)), + ("basicCorrection/tone/exposure", n(0.5)), + ("basicCorrection/tone/contrast", n(25.0)), + ("basicCorrection/tone/highlights", n(-40.0)), + ("basicCorrection/tone/shadows", n(30.0)), + ("basicCorrection/tone/whites", n(10.0)), + ("basicCorrection/tone/blacks", n(-15.0)), + ("basicCorrection/saturation", n(130.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("creative/lookIntensity", n(80.0)), + ("creative/adjustments/fadedFilm", n(30.0)), + ("creative/adjustments/vibrance", n(40.0)), + ("creative/adjustments/creativeSaturation", n(70.0)), + ("creative/adjustments/shadowTint", c(0.3, 0.5, 0.7)), + ("creative/adjustments/highlightTint", c(0.7, 0.55, 0.4)), + ("creative/adjustments/tintBalance", n(-30.0)), + ("curves/curveMasterMidtones", n(30.0)), + ("curves/curveRedShadows", n(-20.0)), + ("curves/curveBlueHighlights", n(40.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("colorWheels/shadowsWheel", c(0.4, 0.5, 0.6)), + ("colorWheels/highlightsWheel", c(0.6, 0.5, 0.45)), + ("vignette/vignetteAmount", n(-2.0)), + ("vignette/vignetteMidpoint", n(30.0)), + ("vignette/vignetteRoundness", n(40.0)), + ("creative/adjustments/sharpen", n(50.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("vignette/vignetteAmount", n(1.5)), + ("vignette/vignetteRoundness", n(-60.0)), + ("vignette/vignetteFeather", n(20.0)), + ("creative/adjustments/sharpen", n(-40.0)), + ], + ); +} + +#[test] +fn linear_color_key() { + effect_case("ec.key.linearcolor", &[("keyColor", c(0.6, 0.5, 0.4)), ("tolerance", n(15.0)), ("softness", n(20.0))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.2, 0.8, 0.3)), ("matchColors", e(1)), ("keyOperation", e(1)), ("view", e(2))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.7, 0.3, 0.6)), ("matchColors", e(2)), ("tolerance", n(5.0)), ("softness", n(30.0))]); +} + +#[test] +fn key_light() { + let screen = ("screenColour", c(0.3, 0.75, 0.35)); + effect_case("ec.keying.keylight", std::slice::from_ref(&screen)); + effect_case( + "ec.keying.keylight", + &[ + // A low gain against a pure screen keeps the raw matte away from 0 (the screen + // removal divides by it; near 0 it amplifies the blurs' float rounding). + ("screenColour", c(0.0, 1.0, 0.0)), + ("screenGain", n(60.0)), + ("screenBalance", n(30.0)), + ("screenPreblur", n(3.0)), + ("screenMatte/clipBlack", n(10.0)), + ("screenMatte/clipWhite", n(80.0)), + ("screenMatte/clipRollback", n(2.5)), + ("screenMatte/screenSoftness", n(3.0)), + ("screenMatte/replaceMethod", e(3)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("unpremultiplyResult", Value::Bool(false)), + ], + ); + effect_case( + "ec.keying.keylight", + &[ + ("screenColour", c(0.2, 0.4, 0.8)), + ("lockBiasesTogether", Value::Bool(false)), + ("alphaBias", c(0.5, 0.45, 0.6)), + ("despillBias", c(0.55, 0.5, 0.45)), + ("screenMatte/clipWhite", n(70.0)), + ("screenMatte/screenDespotBlack", n(2.0)), + ("screenMatte/screenDespotWhite", n(1.5)), + ("screenMatte/screenShrinkGrow", n(-1.5)), + ("screenMatte/replaceMethod", e(2)), + ("foregroundColourCorrection/enableColourCorrection", on()), + ("foregroundColourCorrection/saturation", n(120.0)), + ("foregroundColourCorrection/contrast", n(15.0)), + ("edgeColourCorrection/enableEdgeColourCorrection", on()), + ("edgeColourCorrection/edgeSoftness", n(2.0)), + ("edgeColourCorrection/edgeHardness", n(30.0)), + ("edgeColourCorrection/edgeBrightness", n(-20.0)), + ("sourceCrops/cropLeft", n(13.0)), + ("sourceCrops/cropBottom", n(7.0)), + ("insideMask/sourceAlpha", e(1)), + ], + ); + // Soft Colour without Screen Softness (after the blur, the screen-removed foreground of + // nearly transparent pixels, divided by a raw matte near 1e-4, amplifies float rounding). + effect_case( + "ec.keying.keylight", + &[ + screen.clone(), + ("screenMatte/clipBlack", n(15.0)), + ("screenMatte/clipRollback", n(1.5)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("insideMask/sourceAlpha", e(0)), + ], + ); + for view in [1, 2, 4, 7, 9] { + effect_case("ec.keying.keylight", &[screen.clone(), ("view", e(view)), ("screenMatte/screenShrinkGrow", n(1.0)), ("screenMatte/replaceMethod", e(1))]); + } +} diff --git a/docs/effects.md b/docs/effects.md index 75d32478b..dd479f0bb 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 303 effects with 2434 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 24 effects also run on the GPU compositor with identical results. +- **GPU**: 34 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 261 implemented in full, 42 partial (what is missing is listed). @@ -88,7 +88,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented | | Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented | | Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented | -| Black & White | `ec.color.blackwhite` | 8 | | 32 | Implemented | +| Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | | Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented | | CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented | @@ -97,12 +97,12 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented | | Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented | | Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented | -| Channel Mixer | `ec.color.channelmixer` | 13 | | 32 | Implemented | -| Color Balance | `ec.color.colorbalance` | 10 | | 32 | Implemented | +| Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | +| Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | -| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | +| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | | Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | @@ -111,7 +111,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | | Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | +| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented | @@ -119,11 +119,11 @@ Effects are addressed by id (`ec..`) or display name in commands | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | -| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout | +| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout | | Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | -| Tritone | `ec.color.tritone` | 4 | | 32 | Implemented | -| Vibrance | `ec.color.vibrance` | 2 | | 32 | Implemented | +| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | +| Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented | | Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented | ## Distort @@ -243,8 +243,8 @@ Effects are addressed by id (`ec..`) or display name in commands | Extract | `ec.key.extract` | 6 | | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | -| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented | +| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | +| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | From c4a8c141b563cad36405448f4c0e0379067d4558 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:59:57 -0400 Subject: [PATCH 10/20] M12.7: bench --gpu adds an adjustment-layer comp, speedup and GPU-vs-CPU agreement columns Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 55 +++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 9e1a4a8b7..8aa1381b2 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -749,6 +749,43 @@ fn time_ms(n: usize, mut f: impl FnMut()) -> f64 { median(&mut v) } +/// `bench --gpu`'s adjustment-layer comp: a copy of the first comp with a full-frame adjustment +/// layer on top running Gaussian Blur, Levels, Vibrance and Glow (all GPU effects). +fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option { + use effectcraft_engine::effects; + use effectcraft_engine::keyframe::Value; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{ItemKind, LayerSource, Solid}; + let (_, base) = p.comps().next().map(|(id, c)| (*id, c.clone()))?; + let mut comp = base.clone(); + let (w, h) = (comp.width, comp.height); + let sid = p.add_item( + "Adjustment (bench)", + effectcraft_engine::color::Label::None, + None, + ItemKind::Solid(Solid { color: [1.0; 3], width: w, height: h, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(p, &comp, "Adjustment (bench)", LayerSource::Solid { item: sid }, (w, h), None); + l.switches.adjustment = true; + for (id, vals) in [ + ("ec.blur.gaussian", vec![("blurriness", Value::Scalar(12.0))]), + ("ec.color.levels", vec![("gamma", Value::Scalar(1.3))]), + ("ec.color.vibrance", vec![("vibrance", Value::Scalar(40.0))]), + ("ec.stylize.glow", vec![("threshold", Value::Scalar(55.0))]), + ] { + let spec = effects::find(id)?; + let mut next = p.next_id; + let mut g = effects::instantiate(spec, &mut Ids(&mut next), spec.name, [w as f64, h as f64]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k)?.value = v; + } + l.props.sub_mut("effects")?.children.push(g.into()); + } + comp.layers.insert(0, l); + Some(p.add_item("Adjustment Layers (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into()))) +} + /// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold" /// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of /// unchanged layers: compositing cost); "viewer" is the GPU display path without readback. @@ -756,22 +793,30 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { use effectcraft_render::Backend; let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?; let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize; + // Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a + // full-frame adjustment layer with a GPU effect stack). + let mut project = (*s.project).clone(); let comps: Vec = match args.opt("--comp") { Some(_) => s.active_comp_id().into_iter().collect(), - None => s.project.comps().map(|(id, _)| *id).collect(), + None => { + let mut v: Vec = s.project.comps().map(|(id, _)| *id).collect(); + v.extend(adjustment_bench_comp(&mut project)); + v + } }; + let project = &project; eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu)); eprintln!( "{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu" ); for cid in comps { - let Some(comp) = s.project.comp(cid) else { continue }; - let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let Some(comp) = project.comp(cid) else { continue }; + let name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0).min(comp.duration.seconds() * 0.5)); for (label, scale) in [("Full", 1.0), ("Half", 0.5)] { let mk = |backend: Backend, cache: Option<&LayerCache>| { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = cache; r.accel = Some(&gpu); @@ -792,7 +837,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); }); let view = time_ms(n, || { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = Some(&gcache); r.accel = Some(&gpu); From f85468c6948d1a9a6e20f7234899131b4955c440 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:00:29 -0400 Subject: [PATCH 11/20] M12.7: docs (architecture, parity, gpu crate docs) Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 2 +- crates/gpu/src/lib.rs | 10 ++++++++-- crates/gpu/src/walk.rs | 5 +++-- docs/architecture.md | 26 ++++++++++++++++++++------ docs/parity.md | 13 ++++++------- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7dd14ba83..d6f388831 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -289,7 +289,7 @@ impl GpuContext { let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })) .ok()?; - device.on_uncaptured_error(Arc::new(|e| eprintln!("wgpu: {e}"))); + device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}"))); GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok() } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 2241560b6..bf47e3c58 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -6,8 +6,10 @@ //! layer buffers (once per buffer), applies the layer transforms with the CPU's sampling //! (nearest / bilinear / bicubic, minification pre-filter), motion-blur sub-samples, track //! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc -//! clamping and quantisation, and converts colour spaces. 3D runs, adjustment layers and -//! wireframes run on the CPU between GPU steps (read back, draw, upload). +//! clamping and quantisation, and converts colour spaces. Classic 3D runs composite here too +//! (`classic3d`: per-pixel fragment sort, lights, ray-cast shadows), and adjustment layers run +//! their effect stacks on the GPU-resident comp. Advanced 3D compositing and wireframes run on +//! the CPU between GPU steps (read back, draw, upload). //! //! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR, //! image-based light, shadow maps), see [`Accelerator::raster_3d`]. @@ -16,6 +18,10 @@ //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. //! +//! GPU particles (`particles`): the stepped particle effects hand their simulation to +//! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle +//! and GPU-resident checkpoints. +//! //! Plug a [`Gpu`] into [`Renderer::accel`] (it implements [`Accelerator`]); renders then use it //! when [`RenderOpts::backend`](effectcraft_render::RenderOpts) asks for it. The viewer can //! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index cfec880da..64a1d2e70 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -1,8 +1,9 @@ //! The per-frame compositing walk on the GPU: the same steps as `Renderer::draw_comp` (layer //! order, 3D runs, collapsed precomps, track mattes, Preserve Transparency, layer styles, motion //! blur, bit-depth quantisation, colour conversions), with layer pixels taken from the CPU -//! renderer and its layer cache. Steps without a GPU implementation (3D runs, adjustment and -//! wireframe layers) read the canvas back, run on the CPU and upload the result. +//! renderer and its layer cache. Classic 3D runs and adjustment layers run on the GPU; steps +//! without a GPU implementation (Advanced 3D runs, wireframes, anything a kernel declines) read +//! the canvas back, run on the CPU and upload the result. use std::sync::Arc; diff --git a/docs/architecture.md b/docs/architecture.md index 39acd3962..83c3f3ea3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,17 +157,31 @@ accelerator is attached and the project's renderer is the GPU). A GPU frame walk sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes, knockout, all 38 blend modes (a WGSL port of `color::blend`), the 8/16 bpc clamp-and-quantise steps -and colour-space conversions run on the GPU. 3D runs, adjustment layers and wireframes run on the -CPU between GPU steps (read back, draw, upload). GPU effects (`effects::GPU_EFFECTS`: Gaussian, -Fast Box and Directional Blur, Glow, Levels, Curves, Hue/Saturation, Tint, Fill, Gradient Ramp, -Fractal Noise, Drop Shadow, Brightness & Contrast, Exposure, Invert, Transform) repeat the CPU -effect's steps (padding, box radii, parameters) as kernels; consecutive GPU effects run as one +and colour-space conversions run on the GPU. Classic 3D runs composite on the GPU too +(`gpu::classic3d`): the render crate prepares the planes (`Renderer::prepare_3d_run`: layer buffers +with their bokeh depth of field, homographies per motion-blur sub-sample, materials, lights, track +mattes), the GPU packs the buffers into one atlas texture and one kernel does the per-pixel +fragment sort (intersecting planes, coplanar stack order), Blinn-Phong lighting, ray-cast shadows +against the caster planes (Shadow Diffusion, Light Transmission) and blending. Adjustment layers +run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): +runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D +compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU +effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion, +transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's +steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one chain with one upload and one readback. Tests render scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web (WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the -Info panel's pixel readout reads GPU frames back asynchronously). +Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles** +(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle +Playground cannons without interacting forces evolve every particle independently, so the GPU +backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one +particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the +GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up and pixel agreement for every +comp plus an adjustment-layer comp. **Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are `f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and diff --git a/docs/parity.md b/docs/parity.md index fbe12a5fe..f703644a9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -20,13 +20,12 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking -renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU -rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; +renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur and blend modes; a few viewer and colour-management menu items; OpenType features and variable font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and -the "better than After Effects" items (a plug-in API, branching history, GPU particles). +the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -121,11 +120,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | -| Effects | ≈ 80% | 4.5 | GPU versions of more effects and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -172,8 +171,8 @@ taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. 3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) -4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; 3D runs - and adjustment layers still composite on the CPU). +4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7: + Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles). 5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. 6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress). From b29d833b2d3b9cc69ab11105340829ffee44f71a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:12:36 -0400 Subject: [PATCH 12/20] M13.6: Lumetri Scopes, Footage, Media Browser, Metadata, Progress and Content-Aware Fill panels; Content-Aware Fill, Scene Edit Detection, Auto-trace, Align Video to Data Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/align_data.rs | 256 +++++++++ crates/engine/src/commands/autotrace.rs | 211 +++++++ crates/engine/src/commands/content_fill.rs | 344 ++++++++++++ crates/engine/src/commands/footage_panel.rs | 224 ++++++++ crates/engine/src/commands/frontend.rs | 2 +- crates/engine/src/commands/mod.rs | 14 +- crates/engine/src/commands/panels_cmds.rs | 214 ++++++++ crates/engine/src/commands/scene_detect.rs | 182 ++++++ crates/engine/src/commands/stubs.rs | 8 - crates/engine/src/jobs.rs | 320 +++++++++++ crates/engine/src/lib.rs | 23 + crates/engine/src/media_browser.rs | 270 +++++++++ crates/engine/src/menus.rs | 12 +- crates/engine/src/tests_panels.rs | 518 ++++++++++++++++++ crates/project/src/lib.rs | 4 + crates/ui-egui/src/dock.rs | 34 +- crates/ui-egui/src/lib.rs | 5 + .../ui-egui/src/panels/content_fill_panel.rs | 138 +++++ crates/ui-egui/src/panels/footage_panel.rs | 150 +++++ crates/ui-egui/src/panels/forms.rs | 54 ++ crates/ui-egui/src/panels/media_panels.rs | 366 +++++++++++++ crates/ui-egui/src/panels/mod.rs | 13 + crates/ui-egui/src/panels/panel_kit.rs | 70 +++ crates/ui-egui/src/panels/project.rs | 15 + crates/ui-egui/src/panels/scopes_panel.rs | 197 +++++++ crates/ui-egui/src/panels/timeline.rs | 1 + crates/ui-egui/src/state.rs | 4 + crates/ui-egui/tests/ui_panels.rs | 104 ++++ docs/architecture.md | 36 ++ docs/parity.md | 24 +- 30 files changed, 3786 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/commands/align_data.rs create mode 100644 crates/engine/src/commands/autotrace.rs create mode 100644 crates/engine/src/commands/content_fill.rs create mode 100644 crates/engine/src/commands/footage_panel.rs create mode 100644 crates/engine/src/commands/panels_cmds.rs create mode 100644 crates/engine/src/commands/scene_detect.rs create mode 100644 crates/engine/src/jobs.rs create mode 100644 crates/engine/src/media_browser.rs create mode 100644 crates/engine/src/tests_panels.rs create mode 100644 crates/ui-egui/src/panels/content_fill_panel.rs create mode 100644 crates/ui-egui/src/panels/footage_panel.rs create mode 100644 crates/ui-egui/src/panels/media_panels.rs create mode 100644 crates/ui-egui/src/panels/panel_kit.rs create mode 100644 crates/ui-egui/src/panels/scopes_panel.rs create mode 100644 crates/ui-egui/tests/ui_panels.rs diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs new file mode 100644 index 000000000..1f18de5f2 --- /dev/null +++ b/crates/engine/src/commands/align_data.rs @@ -0,0 +1,256 @@ +//! Layer ▸ Time ▸ Align Video to Data: move a footage layer in time so its frames line up with +//! the samples of a data file (telemetry, GPS, sensor logs imported as data footage). +//! +//! The data's sample with timestamp `T` sits at comp time `dataStart + (T − T₀)` (`T₀` = the +//! first sample's timestamp; `dataStart` defaults to 0). A video frame at source time `s` was +//! recorded at `V₀ + s`, where `V₀` is the video's start: `videoStart` if given, else the +//! footage file's creation date (desktop). Aligning sets the layer's Start Time to +//! `dataStart + V₀ − T₀`, so the frame recorded at `T` plays when the data says `T`. +//! +//! **Supported data** (File ▸ Import as data footage; also in `docs/architecture.md`): +//! - JSON: an array of objects, or an object holding such an array (the first array of objects +//! found at the top level, e.g. `{"samples": [...]}`); +//! - CSV / TSV with a header row (quoted fields allowed). +//! +//! The time key is `key`, else the first field named `time`, `timestamp`, `t`, `date`, +//! `datetime`, `utc`, `gps_time`, `seconds` or `time_s` (any case). Timestamps may be ISO 8601 +//! date-times (`2026-05-01T10:15:30.5Z`, offsets `±hh:mm`), times of day (`10:15:30.5`), +//! timecode (`10:15:30:12` at the footage's rate), Unix seconds, or Unix milliseconds (numbers +//! above 10¹¹). When the data has only times of day, `V₀` is reduced to its UTC time of day. + +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, f_p, has_layers, layer_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// A parsed timestamp: seconds, and whether it carries a date (absolute) or is a time of day / +/// relative number. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Stamp { + pub secs: f64, + pub dated: bool, +} + +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds. +fn clock(s: &str, fps: f64) -> Option { + let parts: Vec<&str> = s.split([':', ';']).collect(); + match parts.as_slice() { + [h, m, sec] => Some(h.trim().parse::().ok()? * 3600.0 + m.parse::().ok()? * 60.0 + sec.parse::().ok()?), + [h, m, sec, f] => { + Some(h.trim().parse::().ok()? * 3600.0 + m.parse::().ok()? * 60.0 + sec.parse::().ok()? + f.parse::().ok()? / fps.max(1.0)) + } + [m, sec] => Some(m.trim().parse::().ok()? * 60.0 + sec.parse::().ok()?), + _ => None, + } +} + +/// Parse a timestamp value (see the module docs). `fps` reads timecode frames. +pub fn parse_stamp(v: &Value, fps: f64) -> Option { + match v { + Value::Number(n) => { + let x = n.as_f64()?; + Some(if x > 1e11 { Stamp { secs: x / 1000.0, dated: true } } else { Stamp { secs: x, dated: x > 1e8 } }) + } + Value::String(s) => { + let s = s.trim(); + if let Ok(x) = s.parse::() { + return parse_stamp(&json!(x), fps); + } + // ISO 8601: date [T| ] time [zone]. + if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { + let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); + let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0; + let rest = s[10..].trim_start_matches(['T', 't', ' ']); + if !rest.is_empty() { + // Zone: Z or ±hh:mm / ±hhmm. + let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + secs += clock(time, fps)?; + if zone.len() > 1 { + let sign = if zone.starts_with('-') { -1.0 } else { 1.0 }; + let z = zone[1..].replace(':', ""); + let (zh, zm) = (z.get(0..2)?.parse::().ok()?, z.get(2..4).and_then(|m| m.parse::().ok()).unwrap_or(0.0)); + secs -= sign * (zh * 3600.0 + zm * 60.0); + } + } + return Some(Stamp { secs, dated: true }); + } + clock(s, fps).map(|secs| Stamp { secs, dated: false }) + } + _ => None, + } +} + +const TIME_KEYS: &[&str] = &["time", "timestamp", "t", "date", "datetime", "utc", "gps_time", "gpstime", "seconds", "time_s", "times"]; + +/// Split a CSV/TSV line (quotes allowed). +fn split_row(line: &str, sep: char) -> Vec { + let mut out = vec![]; + let mut cur = String::new(); + let mut q = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if q && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + '"' => q = !q, + c if c == sep && !q => out.push(std::mem::take(&mut cur)), + c => cur.push(c), + } + } + out.push(cur); + out.into_iter().map(|s| s.trim().to_string()).collect() +} + +/// The time column of data footage: (key used, timestamps in row order). +pub fn data_times(text: &str, format: &str, key: Option<&str>) -> std::result::Result<(String, Vec), String> { + let pick = |names: &[String]| -> Option { + if let Some(k) = key { + return names.iter().find(|n| n.eq_ignore_ascii_case(k)).cloned(); + } + TIME_KEYS.iter().find_map(|t| names.iter().find(|n| n.eq_ignore_ascii_case(t)).cloned()) + }; + if format == "json" { + let v: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?; + let rows = match &v { + Value::Array(a) => a.clone(), + Value::Object(o) => { + o.values().find_map(|x| x.as_array().filter(|a| a.first().is_some_and(Value::is_object)).cloned()).ok_or("no array of samples in the JSON")? + } + _ => return Err("the JSON holds no samples".into()), + }; + let names: Vec = rows.first().and_then(Value::as_object).map(|o| o.keys().cloned().collect()).unwrap_or_default(); + let k = pick(&names) + .ok_or_else(|| format!("no time field (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?; + let times = rows.iter().filter_map(|r| r.get(&k).cloned()).collect(); + return Ok((k, times)); + } + let sep = if format == "tsv" { '\t' } else { ',' }; + let mut lines = text.trim_start_matches('\u{feff}').lines().filter(|l| !l.trim().is_empty()); + let header = split_row(lines.next().ok_or("the file is empty")?, sep); + let k = pick(&header) + .ok_or_else(|| format!("no time column (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?; + let col = header.iter().position(|h| *h == k).unwrap_or(0); + let times = lines.filter_map(|l| split_row(l, sep).get(col).map(|c| json!(c))).collect(); + Ok((k, times)) +} + +fn data_item(s: &Session, p: &Value) -> Result { + let c = "layer.alignVideoToData"; + let id = match p.get("data") { + Some(Value::Number(n)) => n.as_u64().map(ItemId), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id), + _ => s.project.items.values().find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.kind == FootageKind::Data)).map(|i| i.id), + } + .ok_or_else(|| bad(c, "no data footage: import a JSON/CSV/TSV file and pass `data`"))?; + Ok(id) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let c = "layer.alignVideoToData"; + let (cid, lid) = layer_p(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let footage = match layer.source.item().and_then(|i| s.project.item(i)).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != FootageKind::Data => f.clone(), + _ => return Err(bad(c, "select a footage layer")), + }; + let fps = footage.frame_rate.as_f64(); + let did = data_item(s, p)?; + let Some(ItemKind::Footage(df)) = s.project.item(did).map(|i| &i.kind) else { return Err(bad(c, "not data footage")) }; + let text = df.data.clone().ok_or_else(|| bad(c, "the data item holds no data"))?; + let format = df.codec.to_ascii_lowercase(); + let (key, raw) = data_times(&text, &format, str_p(p, "key").filter(|k| !k.is_empty())).map_err(|e| bad(c, e))?; + let stamps: Vec = raw.iter().filter_map(|v| parse_stamp(v, fps)).collect(); + let first = *stamps.first().ok_or_else(|| bad(c, format!("no readable timestamps in `{key}`")))?; + let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) { + Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?, + None => { + let (created, _, _) = crate::media_browser::file_dates(&footage.path); + Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true } + } + }; + // Compare like with like: times of day when the data has no dates. + let v0 = if video.dated && !first.dated { video.secs.rem_euclid(86_400.0) } else { video.secs }; + let data_start = f_p(p, "dataStart").unwrap_or(0.0); + let fr = comp.frame_rate; + let start = fr.snap_nearest(Tick::from_seconds_f64(data_start + v0 - first.secs)); + let shift = start - layer.start_time; + s.edit("Align Video to Data", None, |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + l.start_time += shift; + l.in_point += shift; + l.out_point += shift; + Ok(()) + })?; + Ok(json!({ + "layer": lid.0, + "startTime": start.seconds(), + "shift": shift.seconds(), + "key": key, + "samples": stamps.len(), + "firstSample": first.secs, + "videoStart": v0, + "dataStart": data_start, + })) +} + +pub fn specs() -> Vec { + vec![cmd!( + "layer.alignVideoToData", + "Align Video to Data", + ["Layer", "Time"], + None, + "{layer?, data?: data footage id|name, key?: time field, videoStart?: ISO date-time | hh:mm:ss | timecode | seconds (default: file creation time), dataStart? (comp s of the first sample, 0)}", + has_layers, + run + )] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_timestamps() { + let p = |v: Value| parse_stamp(&v, 25.0).unwrap(); + assert_eq!(p(json!("1970-01-02T00:00:10Z")).secs, 86_410.0); + assert_eq!(p(json!("2000-03-01T00:00:00Z")).secs, 951_868_800.0); + assert_eq!(p(json!("1970-01-01T02:00:00+02:00")).secs, 0.0); + assert_eq!(p(json!("1970-01-01 01:00:00.5-01:00")).secs, 7200.5); + assert_eq!(p(json!("10:00:01.25")), Stamp { secs: 36_001.25, dated: false }); + assert_eq!(p(json!("10:00:01:05")).secs, 36_001.2); + assert_eq!(p(json!(1_700_000_000_500u64)), Stamp { secs: 1_700_000_000.5, dated: true }); + assert_eq!(p(json!(12.5)), Stamp { secs: 12.5, dated: false }); + assert_eq!(p(json!("3.5")).secs, 3.5); + assert!(parse_stamp(&json!("soon"), 25.0).is_none()); + } + + #[test] + fn finds_time_columns() { + let (k, t) = data_times("lat,Time,speed\n1,\"10:00:00\",3\n2,10:00:01,4\n", "csv", None).unwrap(); + assert_eq!(k, "Time"); + assert_eq!(t, vec![json!("10:00:00"), json!("10:00:01")]); + let (k, t) = data_times("a\tts\n1\t5\n", "tsv", Some("ts")).unwrap(); + assert_eq!((k.as_str(), t), ("ts", vec![json!("5")])); + let (k, t) = data_times(r#"{"meta": 1, "samples": [{"timestamp": 3, "v": 1}, {"timestamp": 4}]}"#, "json", None).unwrap(); + assert_eq!((k.as_str(), t), ("timestamp", vec![json!(3), json!(4)])); + assert!(data_times("x,y\n1,2\n", "csv", None).is_err()); + } +} diff --git a/crates/engine/src/commands/autotrace.rs b/crates/engine/src/commands/autotrace.rs new file mode 100644 index 000000000..093468b6f --- /dev/null +++ b/crates/engine/src/commands/autotrace.rs @@ -0,0 +1,211 @@ +//! Layer ▸ Auto-trace…: masks from a layer's alpha, a colour channel or its luminance, at the +//! current frame or over the work area (one Mask Path key per frame). The tracing is +//! `effectcraft_path::trace` (marching squares, Douglas–Peucker, Bezier fitting). + +use effectcraft_color::Label; +use effectcraft_keyframe::{Interp, Keyframe}; +use effectcraft_path::trace::{TraceOpts, Traced, trace}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{ItemKind, LayerSource, MaskMode, Solid, Value as KV}; +use effectcraft_raster::Image; +use effectcraft_render::{RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Alpha, + Red, + Green, + Blue, + Luminance, +} + +impl Channel { + fn from_name(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "alpha" | "a" => Channel::Alpha, + "red" | "r" => Channel::Red, + "green" | "g" => Channel::Green, + "blue" | "b" => Channel::Blue, + "luminance" | "luma" | "l" => Channel::Luminance, + _ => return None, + }) + } +} + +/// The traced scalar field of a frame (straight channel values, 0…1). +pub fn channel_field(img: &Image, ch: Channel, invert: bool) -> Vec { + img.data + .iter() + .map(|p| { + let a = p[3]; + let s = |v: f32| if a > 1e-6 { v / a } else { 0.0 }; + let v = match ch { + Channel::Alpha => a, + Channel::Red => s(p[0]), + Channel::Green => s(p[1]), + Channel::Blue => s(p[2]), + Channel::Luminance => 0.2126 * s(p[0]) + 0.7152 * s(p[1]) + 0.0722 * s(p[2]), + } + .clamp(0.0, 1.0); + if invert { 1.0 - v } else { v } + }) + .collect() +} + +/// Trace one frame: outlines in layer space. +fn trace_frame(img: &Image, offset: [f64; 2], ch: Channel, invert: bool, blur: f64, o: &TraceOpts) -> Vec { + let src = if blur > 0.0 { effectcraft_raster::gaussian_blur(img, blur / 2.0, blur / 2.0, false) } else { img.clone() }; + let field = channel_field(&src, ch, invert); + let mut out = trace(&field, img.width as usize, img.height as usize, o); + for t in &mut out { + for v in &mut t.path.vertices { + v[0] += offset[0]; + v[1] += offset[1]; + } + } + out +} + +fn run(s: &mut Session, p: &Value) -> Result { + let c = "layer.autoTrace"; + let (cid, lid) = layer_p(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); + if !layer.source.is_av() { + return Err(bad(c, "auto-trace needs a footage, solid, precomp, text or shape layer")); + } + let ch = match str_p(p, "channel") { + Some(x) => Channel::from_name(x).ok_or_else(|| bad(c, "channel: alpha|red|green|blue|luminance"))?, + None => Channel::Alpha, + }; + let invert = b_p(p, "invert").unwrap_or(false); + let blur = f_p(p, "blur").unwrap_or(1.0).max(0.0); + let o = TraceOpts { + threshold: (f_p(p, "threshold").unwrap_or(50.0) / 100.0).clamp(0.0, 1.0) as f32, + tolerance: f_p(p, "tolerance").unwrap_or(1.0).max(0.01), + min_area: f_p(p, "minimumArea").unwrap_or(10.0).max(0.0), + corner_roundness: (f_p(p, "cornerRoundness").unwrap_or(50.0) / 100.0).clamp(0.0, 1.0), + }; + let fr = comp.frame_rate; + let work_area = matches!(str_p(p, "timeSpan"), Some("workArea" | "work area" | "work_area")); + let times: Vec = if work_area { + let a = comp.work_area.0.max(layer.in_point); + let b = comp.work_area.1.min(layer.out_point); + let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1))); + (fa..=fb).map(|f| fr.tick_of(f)).collect() + } else { + vec![s.time()] + }; + if times.is_empty() { + return Err(bad(c, "the work area doesn't overlap the layer")); + } + // Trace every frame (in parallel). + let frames: Vec> = { + let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + r.expr = s.expr.as_deref(); + let r = &r; + let (project, expr) = (&s.project, s.expr.as_deref()); + use rayon::prelude::*; + times + .par_iter() + .map(|t| match crate::tracking::source_frame(r, project, cid, &comp, &layer, *t, expr) { + Some((img, off)) => trace_frame(&img, off, ch, invert, blur, &o), + None => vec![], + }) + .collect() + }; + let n_masks = frames.iter().map(Vec::len).max().unwrap_or(0); + if n_masks == 0 { + return Err(bad(c, "nothing to trace: no pixels pass the threshold")); + } + let new_layer = b_p(p, "applyToNewLayer").unwrap_or(false); + let (sw, sh) = effectcraft_render::source_size(&s.project, &layer); + let layer_times: Vec = times.iter().map(|t| layer.layer_time(*t)).collect(); + let target = s.edit("Auto-trace", None, |proj, st| { + let target = if new_layer { + // A solid the size of the layer, in the same place, carries the masks. + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let name = format!("Auto-traced {}", layer.name); + let sid = proj.add_item( + &name, + Label::Red, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: sw.max(1), height: sh.max(1), pixel_aspect: comp.pixel_aspect }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (sw.max(1), sh.max(1)), None); + l.start_time = layer.start_time; + l.in_point = layer.in_point; + l.out_point = layer.out_point; + l.stretch = layer.stretch; + l.parent = layer.parent; + if let (Some(dst), Some(src)) = (l.props.sub_mut("transform"), layer.transform()) { + let mut tr = src.clone(); + tr.reassign_uids(&mut proj.next_id); + *dst = tr; + } + let id = l.id; + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = cm.layers.iter().position(|x| x.id == lid).unwrap_or(0); + cm.layers.insert(at, l); + id + } else { + lid + }; + let mut next = proj.next_id; + let l = super::layer_mut(proj, cid, target)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad(c, "this layer can't have masks"))?; + let base = masks.children.len(); + for k in 0..n_masks { + // Depth decides the mode: outlines add, holes subtract. + let first = frames.iter().find_map(|f| f.get(k)).expect("mask"); + let mode = if first.depth % 2 == 0 { MaskMode::Add } else { MaskMode::Subtract }; + let mode = if k == 0 { MaskMode::Add } else { mode }; + let mut g = build::mask( + &mut Ids(&mut next), + &format!("Auto-trace {}", k + 1), + first.path.clone(), + mode, + build::MASK_COLORS[(base + k) % build::MASK_COLORS.len()], + ); + if frames.len() > 1 { + let path = g.get_mut("path").expect("path"); + for (f, lt) in frames.iter().zip(&layer_times) { + if let Some(tr) = f.get(k) { + effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone()))); + } + } + let op = g.get_mut("opacity").expect("opacity"); + if frames.iter().any(|f| f.get(k).is_none()) { + for (f, lt) in frames.iter().zip(&layer_times) { + let mut key = Keyframe::new(*lt, KV::Scalar(if f.get(k).is_some() { 100.0 } else { 0.0 })); + key.in_interp = Interp::Hold; + key.out_interp = Interp::Hold; + effectcraft_keyframe::set_key(&mut op.keys, key); + } + } + } + masks.children.push(g.into()); + } + proj.next_id = next; + st.selected_layers = vec![target]; + Ok(target) + })?; + Ok(json!({"layer": target.0, "masks": n_masks, "frames": times.len()})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "layer.autoTrace", + "Auto-trace...", + ["Layer"], + None, + "{layer?, timeSpan?: currentFrame|workArea, channel?: alpha|red|green|blue|luminance, invert?, blur? (px, 1), tolerance? (px, 1), threshold? (%, 50), minimumArea? (px, 10), cornerRoundness? (%, 50), applyToNewLayer?}", + has_layers, + run + )] +} diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/commands/content_fill.rs new file mode 100644 index 000000000..b030a9911 --- /dev/null +++ b/crates/engine/src/commands/content_fill.rs @@ -0,0 +1,344 @@ +//! Content-Aware Fill (Window ▸ Content-Aware Fill, Layer ▸ New ▸ Content-Aware Fill Layer): +//! fill the transparent area of a layer (what its masks cut away) across the work area or the +//! whole layer, render the result as a PNG sequence and add it as a "Fill" layer above the +//! source, like After Effects. The algorithms are `effectcraft_raster::inpaint` (Object: +//! flow-guided propagation + PatchMatch; Surface: propagation + one synthesised fill carried by +//! the flow; Edge Blend: membrane fill). It runs as a background job (Window ▸ Progress). + +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build}; +use effectcraft_raster::Image; +use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence}; +use effectcraft_render::{EvalCtx, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::scene_detect::LayerFrames; +use super::{CommandSpec, always, b_p, bad, f_p, has_layers, layer_p, str_p}; +use crate::jobs::Apply; +use crate::{EngineError, Result, Session, cmd}; + +/// Content-Aware Fill panel settings (serde, in [`crate::EditorState`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FillSettings { + /// `object`, `surface` or `edgeBlend`. + pub method: String, + /// `workArea` or `entire` (the layer's duration). + pub range: String, + /// Grow the fill area by this many pixels. + pub alpha_expansion: f64, + /// `none`, `subtle`, `moderate`, `strong`. + pub lighting_correction: String, + /// Reference frame: a layer of the comp and the comp time of the frame to use. + pub reference_layer: Option, + pub reference_time: Option, +} + +impl Default for FillSettings { + fn default() -> Self { + FillSettings { + method: "object".into(), + range: "workArea".into(), + alpha_expansion: 0.0, + lighting_correction: "none".into(), + reference_layer: None, + reference_time: None, + } + } +} + +fn lighting(s: &str) -> Option { + Some(match s { + "none" | "off" => 0.0, + "subtle" => 0.35, + "moderate" => 0.65, + "strong" => 1.0, + _ => return None, + }) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let c = "contentFill.set"; + let mut st = s.state.content_fill.clone(); + if let Some(m) = str_p(p, "method") { + st.method = FillMethod::from_name(m).ok_or_else(|| bad(c, "method: object|surface|edgeBlend"))?.name().into(); + } + if let Some(r) = str_p(p, "range") { + st.range = match r { + "workArea" | "work area" => "workArea".into(), + "entire" | "entireDuration" | "layer" => "entire".into(), + _ => return Err(bad(c, "range: workArea|entire")), + }; + } + if let Some(a) = f_p(p, "alphaExpansion") { + st.alpha_expansion = a.clamp(0.0, 100.0); + } + if let Some(l) = str_p(p, "lightingCorrection") { + lighting(l).ok_or_else(|| bad(c, "lightingCorrection: none|subtle|moderate|strong"))?; + st.lighting_correction = l.into(); + } + match p.get("referenceLayer") { + Some(Value::Null) => st.reference_layer = None, + Some(v) => { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + st.reference_layer = Some(super::resolve_layer(comp, v).ok_or_else(|| bad(c, format!("no layer {v}")))?); + st.reference_time = Some(f_p(p, "referenceTime").unwrap_or(s.time().seconds())); + } + None => { + if let Some(t) = f_p(p, "referenceTime") { + st.reference_time = Some(t); + } + } + } + s.state.content_fill = st.clone(); + Ok(serde_json::to_value(st).unwrap_or_default()) +} + +/// A layer's masked source at comp time `t` on a `w × h` canvas in layer pixels (straight colour +/// with alpha 1 where kept), and the hole (`true` where the alpha is under one half). +fn masked_frame(r: &Renderer, ctx: &EvalCtx, layer: &effectcraft_project::Layer, w: u32, h: u32) -> (Image, Vec) { + let mut img = Image::new(w, h); + let mut hole = vec![true; (w * h) as usize]; + if let Some(buf) = r.layer_input(ctx, layer, 0) { + let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 }; + for y in 0..h { + for x in 0..w { + let (bx, by) = ((x as f64 + 0.5) * sc - buf.offset[0], (y as f64 + 0.5) * sc - buf.offset[1]); + let p = if (sc - 1.0).abs() < 1e-9 { buf.img.get(bx.floor() as i64, by.floor() as i64) } else { buf.img.sample_bilinear(bx, by) }; + let i = (y * w + x) as usize; + if p[3] >= 0.5 { + img.data[i] = [p[0] / p[3], p[1] / p[3], p[2] / p[3], 1.0]; + hole[i] = false; + } + } + } + } + (img, hole) +} + +/// Encode a straight-alpha frame (alpha 1) as 8-bit PNG. +fn png(img: &Image) -> std::result::Result, String> { + let rgba: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2], p[3]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect(); + super::comp_more::encode_png(&rgba, img.width, img.height) +} + +fn default_dir(s: &Session, name: &str) -> String { + let base = s + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).parent().map(|d| d.join("Fill"))) + .unwrap_or_else(|| std::env::temp_dir().join("effectcraft-fill")); + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); + let mut k = 1; + loop { + let d = base.join(format!("{safe}_Fill_{k}")); + if !d.exists() { + return d.to_string_lossy().to_string(); + } + k += 1; + } +} + +fn generate(s: &mut Session, p: &Value) -> Result { + let c = "contentFill.generate"; + let (cid, lid) = layer_p(s, p, c)?; + // Parameters override the panel settings. + let mut st = s.state.content_fill.clone(); + { + let saved = s.state.content_fill.clone(); + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.retain(|k, _| ["method", "range", "alphaExpansion", "lightingCorrection", "referenceLayer", "referenceTime"].contains(&k.as_str())); + } + set(s, &q)?; + std::mem::swap(&mut st, &mut s.state.content_fill); + s.state.content_fill = saved; + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); + if !layer.source.is_av() || layer.source.item().is_none() { + return Err(bad(c, "Content-Aware Fill needs a footage, solid or precomp layer")); + } + let (w, h) = effectcraft_render::source_size(&s.project, &layer); + if w == 0 || h == 0 { + return Err(bad(c, "the layer has no pixels")); + } + let fr = comp.frame_rate; + let (a, b) = + if st.range == "entire" { (layer.in_point, layer.out_point) } else { (comp.work_area.0.max(layer.in_point), comp.work_area.1.min(layer.out_point)) }; + let (a, b) = (a.max(Tick::ZERO), b.min(comp.duration)); + if b <= a { + return Err(bad(c, "the range doesn't overlap the layer")); + } + let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1))); + let times: Vec = (fa..=fb).map(|f| fr.tick_of(f)).collect(); + let opts = FillOpts { + method: FillMethod::from_name(&st.method).unwrap_or_default(), + lighting: lighting(&st.lighting_correction).unwrap_or(0.0), + ..Default::default() + }; + let expand = st.alpha_expansion.round().max(0.0) as usize; + let reference = st.reference_layer.filter(|r| comp.layer(*r).is_some()).map(|r| (r, Tick::from_seconds_f64(st.reference_time.unwrap_or(0.0)))); + let dir = str_p(p, "outputDir").map(str::to_string).unwrap_or_else(|| default_dir(s, &layer.name)); + let lf = LayerFrames::new(s, cid, lid); + let services = s.services.clone(); + let importer = s.importer.clone(); + let wait = b_p(p, "wait").unwrap_or(false); + let name = layer.name.clone(); + s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| { + ctl.message("Reading frames"); + let project = lf.project.clone(); + let comp = project.comp(cid).ok_or("composition missing")?; + let layer = comp.layer(lid).ok_or("layer missing")?; + let mut r = Renderer::new(&project, lf.footage.as_ref(), RenderOpts::default()); + r.expr = lf.expr.as_deref(); + r.cache = Some(&lf.cache); + let ctx0 = EvalCtx { expr: lf.expr.as_deref(), ..EvalCtx::new(&project, cid, comp, Tick::ZERO) }; + let n = times.len() as u64; + let mut frames = Vec::with_capacity(times.len()); + let mut holes = Vec::with_capacity(times.len()); + for (k, t) in times.iter().enumerate() { + let (img, hole) = masked_frame(&r, &ctx0.at(*t), layer, w, h); + holes.push(dilate(&hole, w as usize, h as usize, expand)); + frames.push(img); + if !ctl.progress(k as u64 + 1, n * 3) { + return Err(crate::render_queue::CANCELLED.into()); + } + } + if !holes.iter().any(|h| h.iter().any(|b| *b)) { + return Err("the layer has no transparent area to fill: draw a mask (Subtract) around what to remove".into()); + } + let mut references = vec![]; + if let Some((rl, rt)) = reference + && let Some(l) = comp.layer(rl) + { + let (img, hole) = masked_frame(&r, &ctx0.at(rt), l, w, h); + if hole.iter().all(|b| !*b) { + let k = times.iter().enumerate().min_by_key(|(_, t)| (t.0 - rt.0).abs()).map(|(k, _)| k).unwrap_or(0); + references.push((k, img)); + } + } + ctl.message("Filling"); + let input = FillInput { frames, holes, references }; + let out = fill_sequence(&input, &opts, &mut |d, t| ctl.progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?; + ctl.message("Writing the fill layer"); + std::fs::create_dir_all(&dir).map_err(|e| format!("{dir}: {e}"))?; + let mut paths = vec![]; + for (k, img) in out.iter().enumerate() { + let path = std::path::Path::new(&dir).join(format!("fill_{k:05}.png")).to_string_lossy().to_string(); + services.write_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?; + paths.push(path); + if !ctl.progress(2 * n + k as u64 + 1, n * 3) { + return Err(crate::render_queue::CANCELLED.into()); + } + } + let start = times[0]; + let count = times.len(); + let apply: Apply = Box::new(move |s: &mut Session| add_fill_layer(s, cid, lid, &name, paths, (w, h), start, count, importer)); + Ok(apply) + }) +} + +/// Import the rendered sequence and put it in a layer above the source. +#[allow(clippy::too_many_arguments)] +fn add_fill_layer( + s: &mut Session, + cid: ItemId, + lid: LayerId, + name: &str, + paths: Vec, + size: (u32, u32), + start: Tick, + count: usize, + importer: Option>, +) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let src = comp.layer(lid).ok_or_else(|| bad("contentFill.generate", "the source layer is gone"))?.clone(); + let fr = comp.frame_rate; + let dur = fr.tick_of(count as i64); + let mut footage = importer.and_then(|i| i.probe(&paths[0]).ok()).filter(|f| f.has_video).unwrap_or_else(|| Footage { + path: paths[0].clone(), + kind: FootageKind::Sequence, + width: size.0, + height: size.1, + has_video: true, + codec: "PNG".into(), + ..Default::default() + }); + footage.kind = FootageKind::Sequence; + footage.sequence = paths.clone(); + footage.frame_rate = fr; + footage.duration = dur; + let (lname, item, layer) = s.edit("Content-Aware Fill", None, |proj, st| { + let folder = proj.folder_named("Fill").unwrap_or_else(|| proj.add_item("Fill", Label::Yellow, None, ItemKind::Folder)); + let n = proj.items.values().filter(|i| i.parent == Some(folder)).count() + 1; + let lname = format!("Fill {n} [{name}]"); + let iid = proj.add_item(&lname, Label::Lavender, Some(folder), ItemKind::Footage(footage)); + let mut l = build::layer(proj, &comp, &lname, LayerSource::Footage { item: iid }, size, Some(dur)); + l.start_time = start; + l.in_point = start; + l.out_point = (start + dur).min(comp.duration); + l.parent = src.parent; + l.switches.three_d = src.switches.three_d; + if let (Some(dst), Some(t)) = (l.props.sub_mut("transform"), src.transform()) { + let mut tr = t.clone(); + tr.reassign_uids(&mut proj.next_id); + *dst = tr; + } + let id = l.id; + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = cm.layers.iter().position(|x| x.id == lid).unwrap_or(0); + cm.layers.insert(at, l); + st.selected_layers = vec![id]; + Ok((lname, iid, id)) + })?; + Ok(json!({"layer": layer.0, "item": item.0, "name": lname, "frames": count, "files": paths})) +} + +fn open_panel(s: &mut Session, p: &Value) -> Result { + // Parameters given: generate straight away (agents); else show the panel. + if p.as_object().is_some_and(|o| !o.is_empty()) { + return generate(s, p); + } + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "contentAwareFill"}) }); + Ok(json!({"frontend": "window.panel"})) +} + +pub fn specs() -> Vec { + const P: &str = "{layer?, method?: object|surface|edgeBlend, range?: workArea|entire, alphaExpansion? (px), lightingCorrection?: none|subtle|moderate|strong, referenceLayer?, referenceTime? (s), outputDir?, wait?: bool}"; + vec![ + cmd!( + "contentFill.set", + "Content-Aware Fill Settings", + [], + None, + "{method?: object|surface|edgeBlend, range?: workArea|entire, alphaExpansion? (px), lightingCorrection?: none|subtle|moderate|strong, referenceLayer?: layer|null, referenceTime? (s)}", + always, + set + ), + CommandSpec { + id: "contentFill.generate", + label: "Generate Fill Layer", + menu: &[], + shortcut: None, + params: P, + enabled: has_layers, + run: generate, + journal: true, + }, + CommandSpec { + id: "layer.newContentAwareFill", + label: "Content-Aware Fill Layer...", + menu: &["Layer", "New"], + shortcut: None, + params: P, + enabled: always, + run: open_panel, + journal: true, + }, + ] +} diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs new file mode 100644 index 000000000..4d11a35a7 --- /dev/null +++ b/crates/engine/src/commands/footage_panel.rs @@ -0,0 +1,224 @@ +//! The Footage panel (double-click footage in the Project panel): plays a footage item on its own +//! time ruler, marks In and Out points in source time, and edits the marked range into the active +//! composition with **Overlay Edit** (a new layer at the current time, on top) or **Ripple Insert +//! Edit** (the same, after pushing later layers — and the later part of layers crossing the +//! current time — back by the clip's duration). + +use effectcraft_project::{ItemId, ItemKind, LayerSource, build}; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, comp_id, f_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// What the Footage panel shows (source time). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FootageView { + pub item: ItemId, + pub time: Tick, + #[serde(default)] + pub in_point: Option, + #[serde(default)] + pub out_point: Option, +} + +fn footage_item(s: &Session, p: &Value, cmd: &str) -> Result { + let id = match p.get("item") { + Some(Value::Number(n)) => n.as_u64().map(ItemId), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id), + _ => s.state.footage_panel.as_ref().map(|f| f.item).or_else(|| s.state.project_selection.first().copied()), + } + .ok_or_else(|| bad(cmd, "no footage: pass `item` or open one in the Footage panel"))?; + match s.project.item(id).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Data => Ok(id), + Some(ItemKind::Solid(_)) => Ok(id), + _ => Err(bad(cmd, format!("item {} is not footage", id.0))), + } +} + +/// Source duration and frame rate of a footage / solid item (stills: `None`). +fn source_timing(s: &Session, item: ItemId) -> (Option, effectcraft_time::FrameRate) { + match s.project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still && f.duration > Tick::ZERO => (Some(f.duration), f.frame_rate), + Some(ItemKind::Footage(f)) => (None, f.frame_rate), + _ => (None, s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30)), + } +} + +fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { + let (_, fr) = source_timing(s, item); + if let Some(t) = f_p(p, "time") { + return Some(fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0)))); + } + p.get("frame").and_then(Value::as_i64).map(|f| fr.tick_of(f.max(0))) +} + +fn open(s: &mut Session, p: &Value) -> Result { + let item = footage_item(s, p, "footage.open")?; + if s.state.footage_panel.as_ref().is_none_or(|f| f.item != item) { + s.state.footage_panel = Some(FootageView { item, time: Tick::ZERO, in_point: None, out_point: None }); + } + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "footage"}) }); + Ok(json!(s.state.footage_panel)) +} + +fn view_mut<'a>(s: &'a mut Session, cmd: &str) -> Result<&'a mut FootageView> { + s.state.footage_panel.as_mut().ok_or_else(|| bad(cmd, "the Footage panel shows no footage (footage.open)")) +} + +fn clamp_time(s: &Session, item: ItemId, t: Tick) -> Tick { + let (dur, fr) = source_timing(s, item); + match dur { + Some(d) => t.clamp(Tick::ZERO, (d - fr.frame_duration()).max(Tick::ZERO)), + None => t.max(Tick::ZERO), + } +} + +fn set_time(s: &mut Session, p: &Value) -> Result { + let item = view_mut(s, "footage.setTime")?.item; + let t = source_time(s, p, item).ok_or_else(|| bad("footage.setTime", "missing `time` or `frame`"))?; + let t = clamp_time(s, item, t); + view_mut(s, "footage.setTime")?.time = t; + Ok(json!(t.seconds())) +} + +fn set_mark(s: &mut Session, p: &Value, out: bool) -> Result { + let c = if out { "footage.setOut" } else { "footage.setIn" }; + let v = view_mut(s, c)?.clone(); + let t = source_time(s, p, v.item).unwrap_or(v.time); + let t = clamp_time(s, v.item, t); + let (_, fr) = source_timing(s, v.item); + let view = view_mut(s, c)?; + if out { + // The Out point marks the last frame shown: the range ends after it. + let end = t + fr.frame_duration(); + view.out_point = Some(end); + if view.in_point.is_some_and(|i| i >= end) { + view.in_point = None; + } + } else { + view.in_point = Some(t); + if view.out_point.is_some_and(|o| o <= t) { + view.out_point = None; + } + } + Ok(json!(s.state.footage_panel)) +} + +fn clear_marks(s: &mut Session, _: &Value) -> Result { + let v = view_mut(s, "footage.clearInOut")?; + v.in_point = None; + v.out_point = None; + Ok(Value::Null) +} + +/// Overlay / Ripple Insert Edit of the marked range at the comp's current time. +fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result { + let c = if ripple { "footage.rippleInsertEdit" } else { "footage.overlayEdit" }; + let item = footage_item(s, p, c)?; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let view = s.state.footage_panel.clone().filter(|v| v.item == item); + let (dur, _) = source_timing(s, item); + let src_in = f_p(p, "in").map(Tick::from_seconds_f64).or(view.as_ref().and_then(|v| v.in_point)).unwrap_or(Tick::ZERO); + let fr = comp.frame_rate; + let at = fr.snap_nearest(f_p(p, "at").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid))).max(Tick::ZERO); + let src_out = f_p(p, "out").map(Tick::from_seconds_f64).or(view.as_ref().and_then(|v| v.out_point)).or(dur).unwrap_or(src_in + (comp.duration - at)); + if src_out <= src_in { + return Err(bad(c, "the Out point must be after the In point")); + } + let len = fr.snap_nearest(src_out - src_in).max(fr.frame_duration()); + if at >= comp.duration { + return Err(bad(c, "the current time is past the end of the composition")); + } + let it = s.project.item(item).cloned().ok_or_else(|| bad(c, "no such item"))?; + let (src, size) = match &it.kind { + ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height)), + ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height)), + _ => return Err(bad(c, "not footage")), + }; + let label = if ripple { "Ripple Insert Edit" } else { "Overlay Edit" }; + let id = s.edit(label, None, |proj, st| { + let mut next = proj.next_id; + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if ripple { + let mut added = vec![]; + for i in 0..cm.layers.len() { + let l = &mut cm.layers[i]; + if l.in_point >= at { + l.start_time += len; + l.in_point += len; + l.out_point += len; + } else if l.out_point > at { + // Split: the part after the current time moves back. + let mut b = l.clone(); + super::edit::reid(&mut b, &mut next); + l.out_point = at; + b.in_point = at + len; + b.out_point += len; + b.start_time += len; + added.push((i, b)); + } + } + for (i, b) in added.into_iter().rev() { + cm.layers.insert(i, b); + } + } + proj.next_id = next; + let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len)); + l.start_time = at - src_in; + l.in_point = at; + l.out_point = (at + len).min(comp.duration); + let lid = l.id; + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + cm.layers.insert(0, l); + st.selected_layers = vec![lid]; + st.selected_props.clear(); + st.selected_keys.clear(); + Ok(lid) + })?; + Ok(json!({"layer": id.0, "in": at.seconds(), "duration": len.seconds()})) +} + +fn has_view_or_footage(s: &Session) -> std::result::Result<(), String> { + if s.state.footage_panel.is_some() { Ok(()) } else { Err("open footage in the Footage panel first".into()) } +} + +fn can_edit(s: &Session) -> std::result::Result<(), String> { + has_view_or_footage(s)?; + super::has_comp(s) +} + +fn info(s: &mut Session, _: &Value) -> Result { + let Some(v) = &s.state.footage_panel else { return Ok(Value::Null) }; + let (dur, fr) = source_timing(s, v.item); + let name = s.project.item(v.item).map(|i| i.name.clone()).unwrap_or_default(); + Ok( + json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": fr.frame_at(v.time), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}), + ) +} + +pub fn specs() -> Vec { + vec![ + cmd!("footage.open", "Open in Footage Panel", [], None, "{item: id|name}", always, open), + crate::query!("footage.info", "Footage Panel State", "{}", info), + cmd!("footage.setTime", "Footage Panel Time", [], None, "{time? (s, source) | frame?}", has_view_or_footage, set_time), + cmd!("footage.setIn", "Set In Point", [], None, "{time? | frame? (default: the panel's time)}", has_view_or_footage, |s, p| set_mark(s, p, false)), + cmd!("footage.setOut", "Set Out Point", [], None, "{time? | frame? (default: the panel's time)}", has_view_or_footage, |s, p| set_mark(s, p, true)), + cmd!("footage.clearInOut", "Clear In and Out", [], None, "{}", has_view_or_footage, clear_marks), + cmd!("footage.overlayEdit", "Overlay Edit", [], None, "{item?, comp?, in? (s), out? (s), at? (comp s; default the current time)}", can_edit, |s, p| { + edit_into_comp(s, p, false) + }), + cmd!( + "footage.rippleInsertEdit", + "Ripple Insert Edit", + [], + None, + "{item?, comp?, in? (s), out? (s), at? (comp s; default the current time)}", + can_edit, + |s, p| edit_into_comp(s, p, true) + ), + ] +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 168e77a54..7abd29238 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Show Panel", [], None, - "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools}", + "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}", always ), fe!( diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 39fa4234f..12324cf45 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,12 +1,15 @@ //! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, //! shortcut and viewer/timeline gesture maps to one of these. +mod align_data; mod anim; pub mod anim_tools; mod animation; +mod autotrace; mod camera_cmds; mod comp; -mod comp_more; +pub(crate) mod comp_more; +pub mod content_fill; mod create; mod edit; mod effect; @@ -15,6 +18,7 @@ pub mod expr_tools; mod file; pub(crate) mod file_more; mod focus; +pub mod footage_panel; mod frontend; mod help; mod key_labels; @@ -30,6 +34,7 @@ mod mask; pub mod mask_interp; pub(crate) mod model3d; pub mod paint; +pub mod panels_cmds; mod paths; mod project_items; mod prop; @@ -39,6 +44,7 @@ pub mod puppet; mod query; mod render_queue; pub mod roto_cmds; +mod scene_detect; mod settings; mod shape_stroke; mod stubs; @@ -156,6 +162,12 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(essential::specs()); v.extend(expr_tools::specs()); v.extend(proxy::specs()); + v.extend(panels_cmds::specs()); + v.extend(footage_panel::specs()); + v.extend(autotrace::specs()); + v.extend(scene_detect::specs()); + v.extend(align_data::specs()); + v.extend(content_fill::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs new file mode 100644 index 000000000..eb0568781 --- /dev/null +++ b/crates/engine/src/commands/panels_cmds.rs @@ -0,0 +1,214 @@ +//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels. + +use effectcraft_project::ItemId; +use effectcraft_raster::Image; +use effectcraft_raster::scopes::{self, ColorStandard, ScopeKind, ScopeOpts}; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session, cmd, media_browser as mb, query}; + +// ---------------------------------------------------------------- Progress + +fn jobs_list(s: &mut Session, _: &Value) -> Result { + s.poll_jobs(); + Ok(json!({"running": s.jobs(), "finished": s.job_log})) +} + +fn jobs_cancel(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "job").unwrap_or("all"); + Ok(json!({"cancelled": s.cancel_job(id)})) +} + +fn jobs_wait(s: &mut Session, _: &Value) -> Result { + s.wait_jobs(); + Ok(json!({"finished": s.job_log})) +} + +// ---------------------------------------------------------------- Media Browser + +fn browser_dir(s: &Session, p: &Value) -> String { + str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(mb::home) +} + +fn browser_list(s: &mut Session, p: &Value) -> Result { + let dir = browser_dir(s, p); + let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only); + let entries = mb::list(&dir, only).map_err(EngineError::Other)?; + Ok(json!({"path": dir, "parent": mb::parent(&dir), "entries": entries, "favorites": s.state.media_browser.favorites})) +} + +fn browser_enabled(_: &Session) -> std::result::Result<(), String> { + if mb::available() { Ok(()) } else { Err("the Media Browser needs the desktop app".into()) } +} + +fn browser_go(s: &mut Session, p: &Value) -> Result { + let dir = match str_p(p, "path") { + Some("..") => s.state.media_browser.folder.clone().and_then(|d| mb::parent(&d)).unwrap_or_else(mb::home), + Some(d) => d.to_string(), + None => mb::home(), + }; + if let Some(b) = b_p(p, "importableOnly") { + s.state.media_browser.importable_only = b; + } + // Validate before switching. + mb::list(&dir, false).map_err(EngineError::Other)?; + s.state.media_browser.folder = Some(dir.clone()); + browser_list(s, &json!({"path": dir})) +} + +fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result { + let dir = browser_dir(s, p); + let favs = &mut s.state.media_browser.favorites; + favs.retain(|f| f != &dir); + if add { + favs.push(dir); + } + Ok(json!({"favorites": favs})) +} + +fn browser_import(s: &mut Session, p: &Value) -> Result { + let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?; + let r = s.execute("file.import", json!({"paths": paths}))?; + // Add to the active comp (drag into the timeline). + if b_p(p, "addToComp").unwrap_or(false) + && s.active_comp_id().is_some() + && let Some(items) = r.get("items").and_then(Value::as_array) + { + for it in items { + s.execute("layer.addItem", json!({"item": it}))?; + } + } + Ok(r) +} + +fn file_info(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?; + let (created, modified, size) = mb::file_dates(path); + let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)}); + if let Some(imp) = s.importer.clone() + && matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model")) + && let Ok(f) = imp.probe(path) + { + v["footage"] = mb::footage_metadata(&f); + } + Ok(v) +} + +// ---------------------------------------------------------------- Metadata + +fn item_ref(s: &Session, p: &Value) -> Option { + match p.get("item") { + Some(Value::Number(n)) => n.as_u64().map(ItemId), + Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name).map(|i| i.id), + _ => s.state.project_selection.first().copied(), + } +} + +fn metadata(s: &mut Session, p: &Value) -> Result { + let project = mb::project_metadata(&s.project, s.path.as_deref()); + let item = match item_ref(s, p) { + Some(id) => Some(mb::item_metadata(&s.project, s.project.item(id).ok_or_else(|| bad("item.metadata", format!("no item {}", id.0)))?)), + None => None, + }; + Ok(json!({"project": project, "item": item})) +} + +fn project_comment(s: &mut Session, p: &Value) -> Result { + let text = str_p(p, "comment").ok_or_else(|| bad("project.setProjectComment", "missing `comment`"))?.to_string(); + s.edit("Project Comment", super::merge_p(p), |proj, _| { + proj.settings.comment = text; + Ok(()) + })?; + Ok(Value::Null) +} + +// ---------------------------------------------------------------- Lumetri Scopes + +/// The comp frame the scopes read (the viewer's image: over the comp background), at most +/// `max_side` pixels on the long side. +pub fn scope_frame(s: &Session, comp: ItemId, t: Tick, max_side: u32) -> Option { + let c = s.project.comp(comp)?; + let long = c.width.max(c.height).max(1) as f64; + let scale = (max_side as f64 / long).min(1.0); + let mut img = s.render(comp, t, RenderOpts { scale, ..Default::default() }); + let bg = c.background; + for p in &mut img.data { + let k = 1.0 - p[3].clamp(0.0, 1.0); + *p = [p[0] + bg[0] * k, p[1] + bg[1] * k, p[2] + bg[2] * k, 1.0]; + } + Some(img) +} + +/// Scope options from parameters. +pub fn scope_opts(p: &Value) -> std::result::Result<(ScopeKind, ScopeOpts), String> { + let kind = match str_p(p, "scope") { + Some(k) => ScopeKind::from_name(k).ok_or_else(|| format!("unknown scope `{k}`; one of {}", ScopeKind::ALL.map(|k| k.name()).join(", ")))?, + None => ScopeKind::WaveformRgb, + }; + let standard = match str_p(p, "standard") { + Some(x) => ColorStandard::from_name(x).ok_or_else(|| format!("standard: rec601|rec709|rec2020, not `{x}`"))?, + None => ColorStandard::Rec709, + }; + let size = f_p(p, "size").unwrap_or(256.0).clamp(16.0, 1024.0) as u32; + Ok((kind, ScopeOpts { standard, float: b_p(p, "float").unwrap_or(false), clamp: b_p(p, "clamp").unwrap_or(true), width: size, height: size })) +} + +fn scopes_analyze(s: &mut Session, p: &Value) -> Result { + let cid = super::comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = super::time_p(s, p, Some(comp)); + let (kind, o) = scope_opts(p).map_err(|e| bad("scopes.analyze", e))?; + let img = scope_frame(s, cid, t, 512).ok_or(EngineError::NoComp)?; + let sc = scopes::compute(&img, kind, &o); + // Signal statistics of the frame. + let n = img.data.len().max(1) as f64; + let mut mean = [0.0f64; 3]; + let (mut ymin, mut ymax, mut ysum) = (f64::MAX, f64::MIN, 0.0); + for px in &img.data { + for c in 0..3 { + mean[c] += px[c] as f64 / n; + } + let y = o.standard.ycbcr([px[0], px[1], px[2]])[0] as f64; + ymin = ymin.min(y); + ymax = ymax.max(y); + ysum += y; + } + let mut v = json!({ + "scope": kind.name(), + "width": sc.width, + "height": sc.height, + "peaks": [sc.peak(0), sc.peak(1), sc.peak(2)], + "totals": [sc.total(0), sc.total(1), sc.total(2)], + "mean": mean, + "luma": {"min": ymin, "max": ymax, "mean": ysum / n}, + }); + if kind == ScopeKind::Histogram { + v["histogram"] = json!(scopes::histogram_bars(&sc)); + } + Ok(v) +} + +pub fn specs() -> Vec { + vec![ + query!("jobs.list", "Background Jobs", "{}", jobs_list), + cmd!("jobs.cancel", "Cancel Job", [], None, "{job?: render|track|maskTrack|warp|camera|roto|task:|all}", always, jobs_cancel), + query!("jobs.wait", "Wait for Background Jobs", "{}", jobs_wait), + query!("mediaBrowser.list", "List Folder", "{path?, importableOnly?}", browser_list), + cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go), + cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)), + cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)), + cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import), + query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info), + query!("item.metadata", "Metadata", "{item?: id|name}", metadata), + cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment), + query!( + "scopes.analyze", + "Lumetri Scopes", + "{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}", + scopes_analyze + ), + ] +} diff --git a/crates/engine/src/commands/scene_detect.rs b/crates/engine/src/commands/scene_detect.rs new file mode 100644 index 000000000..8d523e217 --- /dev/null +++ b/crates/engine/src/commands/scene_detect.rs @@ -0,0 +1,182 @@ +//! Layer ▸ Scene Edit Detection…: find the cuts in a footage (or precomp) layer and Create +//! Markers, Split Layers, or Split and Precompose at them. The analysis +//! (`effectcraft_raster::cuts`: colour histograms + motion-compensated difference, adaptive peak +//! threshold) runs as a background job (Window ▸ Progress). + +use std::sync::Arc; + +use effectcraft_project::{ItemId, LayerId, Marker, Project}; +use effectcraft_raster::cuts::{self, CutOpts}; +use effectcraft_render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_p, str_p}; +use crate::jobs::Apply; +use crate::{EngineError, Result, Session, cmd}; + +/// What a background job needs to render a layer's frames. +#[derive(Clone)] +pub(crate) struct LayerFrames { + pub project: Arc, + pub footage: Arc, + pub expr: Option>, + pub cache: Arc, + pub comp: ItemId, + pub layer: LayerId, +} + +impl LayerFrames { + pub fn new(s: &Session, comp: ItemId, layer: LayerId) -> LayerFrames { + LayerFrames { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), cache: s.layer_cache.clone(), comp, layer } + } + /// Run `f` with a frame getter: comp time → (layer source pixels, offset in layer space). + pub fn with(&self, f: impl FnOnce(&(dyn Fn(Tick) -> Option<(Arc, [f64; 2])> + Sync)) -> R) -> Option { + let comp = self.project.comp(self.comp)?; + let layer = comp.layer(self.layer)?; + let mut r = Renderer::new(&self.project, self.footage.as_ref(), RenderOpts::default()); + r.expr = self.expr.as_deref(); + r.cache = Some(&self.cache); + let r = &r; + let get = |t: Tick| crate::tracking::source_frame(r, &self.project, self.comp, comp, layer, t, self.expr.as_deref()); + Some(f(&get)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + Markers, + Split, + SplitPrecompose, +} + +/// Cut times (comp time) of the layer's frames `times` (first frame never a cut). +pub(crate) fn analyse( + lf: &LayerFrames, + times: &[Tick], + o: &CutOpts, + progress: &dyn Fn(u64, u64) -> bool, +) -> std::result::Result<(Vec, Vec), String> { + lf.with(|get| { + let mut sigs = Vec::with_capacity(times.len()); + // Signatures in batches (parallel), with progress and cancel between batches. + for (k, chunk) in times.chunks(8).enumerate() { + use rayon::prelude::*; + let batch: Vec = chunk.par_iter().map(|t| get(*t).map(|(img, _)| cuts::signature(&img)).unwrap_or_default()).collect(); + sigs.extend(batch); + if !progress(((k + 1) * 8).min(times.len()) as u64, times.len() as u64) { + return Err(crate::render_queue::CANCELLED.to_string()); + } + } + let mut scores = vec![0.0f32]; + for i in 1..sigs.len() { + scores.push(cuts::dissimilarity(&sigs[i - 1], &sigs[i])); + } + let cuts = cuts::detect(&scores, o); + Ok((cuts.into_iter().map(|i| times[i]).collect(), scores)) + }) + .ok_or_else(|| "layer missing".to_string())? +} + +fn run(s: &mut Session, p: &Value) -> Result { + let c = "layer.sceneEditDetection"; + let (cid, lid) = layer_p(s, p, c)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + if layer.source.item().is_none() { + return Err(bad(c, "scene edit detection needs a footage or precomp layer")); + } + let mode = match str_p(p, "mode").unwrap_or("markers") { + "markers" | "createMarkers" => Mode::Markers, + "split" | "splitLayers" => Mode::Split, + "splitPrecompose" | "splitAndPrecompose" => Mode::SplitPrecompose, + x => return Err(bad(c, format!("mode: markers|split|splitPrecompose, not `{x}`"))), + }; + let fr = comp.frame_rate; + let (a, b) = (fr.frame_at(fr.snap_nearest(layer.in_point.max(Tick::ZERO))), fr.frame_at(layer.out_point.min(comp.duration) - Tick(1))); + let times: Vec = (a..=b).map(|f| fr.tick_of(f)).collect(); + if times.len() < 2 { + return Err(bad(c, "the layer is too short")); + } + let o = CutOpts { threshold: f_p(p, "threshold").map(|t| (t as f32).clamp(0.01, 1.0)).unwrap_or(CutOpts::default().threshold), ..Default::default() }; + let lf = LayerFrames::new(s, cid, lid); + let name = layer.name.clone(); + let wait = b_p(p, "wait").unwrap_or(false); + s.spawn_task("sceneDetect", format!("Scene Edit Detection: {name}"), wait, move |ctl| { + let (cut_times, _) = analyse(&lf, ×, &o, &|d, t| ctl.progress(d, t))?; + let apply: Apply = Box::new(move |s: &mut Session| apply(s, cid, lid, mode, &cut_times)); + Ok(apply) + }) +} + +fn apply(s: &mut Session, cid: ItemId, lid: LayerId, mode: Mode, cuts: &[Tick]) -> Result { + let secs: Vec = cuts.iter().map(|t| t.seconds()).collect(); + if cuts.is_empty() { + return Ok(json!({"cuts": secs, "layers": [lid.0]})); + } + if mode == Mode::Markers { + s.edit("Scene Edit Detection", None, |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + for (i, t) in cuts.iter().enumerate() { + let lt = l.layer_time(*t); + l.markers.retain(|m| m.time != lt); + l.markers.push(Marker { time: lt, comment: format!("Scene {}", i + 2), ..Default::default() }); + } + l.markers.sort_by_key(|m| m.time); + Ok(()) + })?; + return Ok(json!({"cuts": secs, "layers": [lid.0]})); + } + let snap = s.project.clone(); + // Split from the source layer: each piece ends at the next cut (pieces stacked like Split + // Layer: later pieces above). + let pieces = s.edit("Scene Edit Detection", None, |proj, st| { + let mut next = proj.next_id; + let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let i = cm.layers.iter().position(|l| l.id == lid).ok_or(EngineError::NoComp)?; + let mut ids = vec![lid]; + for t in cuts { + let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i); + let l = &mut cm.layers[cur]; + if !(*t > l.in_point && *t < l.out_point) { + continue; + } + let mut b = l.clone(); + super::edit::reid(&mut b, &mut next); + b.in_point = *t; + l.out_point = *t; + ids.push(b.id); + cm.layers.insert(cur, b); + } + proj.next_id = next; + st.selected_layers = ids.clone(); + Ok(ids) + })?; + let mut out = pieces.clone(); + if mode == Mode::SplitPrecompose { + let base = s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.name.clone()).unwrap_or_default(); + out.clear(); + for (k, id) in pieces.iter().enumerate() { + let r = s.execute( + "layer.precompose", + json!({"comp": cid.0, "layers": [id.0], "mode": "move", "adjustDuration": true, "name": format!("{base} Scene {}", k + 1)}), + )?; + let new = r.get("layer").and_then(Value::as_u64).map(LayerId).unwrap_or(*id); + out.push(new); + } + s.collapse_undo("Scene Edit Detection", &snap); + } + Ok(json!({"cuts": secs, "layers": out.iter().map(|l| l.0).collect::>()})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "layer.sceneEditDetection", + "Scene Edit Detection...", + ["Layer"], + None, + "{layer?, mode?: markers|split|splitPrecompose, threshold? (0…1, 0.25), wait?: bool}", + has_layers, + run + )] +} diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 273ecf998..440464c05 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -17,13 +17,7 @@ macro_rules! stub { pub fn specs() -> Vec { vec![ - // Layer ▸ New (3D / content-aware fill). - stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), // Mask options not in the mask model yet; pen / vertex editing (timeline milestone). - // Time (timeline milestone). - stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"), - stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), - stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), // Cameras / lights / materials / 3D views (3D milestone). stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"), stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"), @@ -39,7 +33,5 @@ pub fn specs() -> Vec { // Keyframes / text / tracking. stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), - // Workspaces and panels that don't exist yet. - stub!("window.unavailablePanel", "Panel", [], "{panel}"), ] } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs new file mode 100644 index 000000000..39a169158 --- /dev/null +++ b/crates/engine/src/jobs.rs @@ -0,0 +1,320 @@ +//! Background jobs: one list (Window ▸ Progress, `jobs.list`, `jobs.cancel`) over the render +//! queue, the analyses (tracker, mask tracker, Warp Stabilizer, 3D Camera Tracker, Roto Brush) and +//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection). +//! +//! A generic task runs a closure on a background thread (inline on wasm32 or when the caller +//! waits). The closure reports progress through [`TaskCtl`] and returns an [`Apply`]: a function +//! the session runs on the UI thread when [`Session::poll_jobs`] sees the task finish (that is +//! where the project is edited, as one undo step). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::Session; +use crate::tracking::lock; + +/// Applied to the session when a task finishes successfully. +pub type Apply = Box crate::Result + Send>; + +/// Progress reporting and cancellation for a running task. +#[derive(Default)] +pub struct TaskCtl { + cancel: AtomicBool, + state: Mutex, +} + +#[derive(Clone, Debug, Default)] +struct TaskState { + done: u64, + total: u64, + message: String, + finished: bool, +} + +impl TaskCtl { + /// Report progress; returns `false` once the task was cancelled. + pub fn progress(&self, done: u64, total: u64) -> bool { + { + let mut s = lock(&self.state); + s.done = done; + s.total = total; + } + crate::offload::report(done, total); + !self.cancelled() + } + pub fn message(&self, m: impl Into) { + lock(&self.state).message = m.into(); + } + pub fn cancelled(&self) -> bool { + self.cancel.load(Ordering::Relaxed) + } +} + +type Work = Box Result + Send>; + +/// A generic background task. +pub struct Task { + pub id: u64, + pub kind: &'static str, + pub label: String, + pub ctl: Arc, + started: web_time::Instant, + result: Arc>>>, + thread: Option>, +} + +/// A finished job (kept for the Progress panel and agents). +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobRecord { + pub id: String, + pub kind: String, + pub label: String, + /// `done`, `cancelled` or `failed`. + pub status: String, + pub message: String, + pub result: Value, + pub seconds: f64, +} + +/// One row of the Progress panel. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobInfo { + /// `render`, `track`, `maskTrack`, `warp`, `camera`, `roto`, or `task:`. + pub id: String, + pub kind: String, + pub label: String, + pub done: u64, + pub total: u64, + /// 0…1 (`None` when the total is not known yet). + pub fraction: Option, + pub running: bool, + pub message: String, + pub cancellable: bool, +} + +fn info(id: &str, kind: &str, label: String, done: u64, total: u64, running: bool, message: String) -> JobInfo { + JobInfo { + id: id.into(), + kind: kind.into(), + label, + done, + total, + fraction: (total > 0).then(|| (done as f64 / total as f64).clamp(0.0, 1.0)), + running, + message, + cancellable: running, + } +} + +impl Session { + /// Start a background task. `wait` (and wasm32) runs it to completion and applies it before + /// returning; the result is then the apply's value. + pub fn spawn_task( + &mut self, + kind: &'static str, + label: impl Into, + wait: bool, + work: impl FnOnce(&TaskCtl) -> Result + Send + 'static, + ) -> crate::Result { + let id = self.next_task_id; + self.next_task_id += 1; + let ctl = Arc::new(TaskCtl::default()); + let result: Arc>>> = Arc::new(Mutex::new(None)); + let label = label.into(); + let wait = wait || cfg!(target_arch = "wasm32"); + let work: Work = Box::new(work); + let run = { + let (ctl, result) = (ctl.clone(), result.clone()); + move || { + let r = work(&ctl); + *lock(&result) = Some(r); + lock(&ctl.state).finished = true; + } + }; + let thread = if wait { + run(); + None + } else { + Some(std::thread::Builder::new().name(format!("task-{kind}")).spawn(run).map_err(|e| crate::EngineError::Other(e.to_string()))?) + }; + self.tasks.push(Task { id, kind, label, ctl, started: web_time::Instant::now(), result, thread }); + if wait { + let rec = self.finish_task(self.tasks.len() - 1); + return match rec.status.as_str() { + "done" => Ok(rec.result), + _ => Err(crate::EngineError::Other(if rec.message.is_empty() { rec.status } else { rec.message })), + }; + } + Ok(json!({"job": format!("task:{id}"), "started": true})) + } + + fn finish_task(&mut self, i: usize) -> JobRecord { + let mut t = self.tasks.remove(i); + if let Some(th) = t.thread.take() { + let _ = th.join(); + } + let r = lock(&t.result).take(); + let cancelled = t.ctl.cancelled(); + let (status, message, result) = match r { + Some(Ok(apply)) if !cancelled => match apply(self) { + Ok(v) => ("done", String::new(), v), + Err(e) => ("failed", e.to_string(), Value::Null), + }, + Some(Ok(_)) => ("cancelled", String::new(), Value::Null), + Some(Err(e)) if cancelled || e == crate::render_queue::CANCELLED => ("cancelled", String::new(), Value::Null), + Some(Err(e)) => ("failed", e, Value::Null), + None => ("failed", "the task stopped".into(), Value::Null), + }; + let rec = JobRecord { + id: format!("task:{}", t.id), + kind: t.kind.into(), + label: t.label.clone(), + status: status.into(), + message: message.clone(), + result, + seconds: t.started.elapsed().as_secs_f64(), + }; + let toast = match status { + "done" => format!("{} finished", t.label), + "cancelled" => format!("{} cancelled", t.label), + _ => format!("{} failed: {message}", t.label), + }; + self.events.push(crate::Event::Toast { message: toast, error: status == "failed" }); + self.job_log.push(rec.clone()); + if self.job_log.len() > 50 { + self.job_log.remove(0); + } + rec + } + + /// Apply finished tasks. Frontends call this every frame while [`Session::tasks`] is not empty. + pub fn poll_jobs(&mut self) -> bool { + let mut any = false; + while let Some(i) = self.tasks.iter().position(|t| lock(&t.ctl.state).finished) { + self.finish_task(i); + any = true; + } + any + } + + /// Wait for every running task (CLI, tests). + pub fn wait_jobs(&mut self) { + while !self.tasks.is_empty() { + if let Some(th) = self.tasks.iter_mut().find_map(|t| t.thread.take()) { + let _ = th.join(); + } + self.poll_jobs(); + if self.tasks.iter().all(|t| t.thread.is_none() && !lock(&t.ctl.state).finished) { + break; + } + } + } + + /// Every running background job. + pub fn jobs(&self) -> Vec { + let mut out = vec![]; + if let Some(st) = self.render_progress() + && !st.finished + { + let name = st + .current + .and_then(|id| self.project.render_queue.iter().find(|i| i.id == id)) + .and_then(|i| self.project.comp(i.comp).map(|_| i.comp)) + .and_then(|c| self.project.item(c)) + .map(|i| i.name.clone()); + out.push(info( + "render", + "render", + format!("Render Queue{}", name.map(|n| format!(": {n}")).unwrap_or_default()), + st.done, + st.total, + true, + format!("item {} of {}", (st.items_done + 1).min(st.items_total.max(1)), st.items_total), + )); + } + if let Some(p) = self.track_progress() + && !p.finished + { + out.push(info("track", "track", "Track Motion".into(), p.done, p.total, true, String::new())); + } + if let Some(p) = self.mask_track_progress() + && !p.finished + { + out.push(info("maskTrack", "maskTrack", "Track Mask".into(), p.done, p.total, true, String::new())); + } + if let Some(p) = self.warp_progress() + && !p.finished + { + out.push(info("warp", "warp", "Warp Stabilizer analysis".into(), p.done, p.total, true, format!("step {}", p.step))); + } + if let Some(p) = self.camera_progress() + && !p.finished + { + out.push(info("camera", "camera", "3D Camera Tracker analysis".into(), p.done, p.total, true, format!("step {}", p.step))); + } + if let Some(p) = self.roto_progress() + && !p.finished + { + out.push(info("roto", "roto", "Roto Brush propagation".into(), p.done, p.total, true, String::new())); + } + for t in &self.tasks { + let s = lock(&t.ctl.state).clone(); + out.push(info(&format!("task:{}", t.id), t.kind, t.label.clone(), s.done, s.total, !s.finished, s.message)); + } + out + } + + /// Cancel a job by id (`render`, `track`, …, `task:`; `all` cancels everything). + pub fn cancel_job(&mut self, id: &str) -> bool { + match id { + "render" => self.stop_render(), + "track" => self.stop_track(), + "maskTrack" => self.stop_mask_track(), + "warp" => self.stop_warp(), + "camera" => self.stop_camera(), + "roto" => self.stop_roto(), + "all" => { + let ids: Vec = self.jobs().into_iter().map(|j| j.id).collect(); + ids.iter().fold(false, |a, j| self.cancel_job(j) | a) + } + _ => { + let Some(n) = id.strip_prefix("task:").and_then(|n| n.parse::().ok()) else { return false }; + match self.tasks.iter().find(|t| t.id == n) { + Some(t) => { + t.ctl.cancel.store(true, Ordering::Relaxed); + true + } + None => false, + } + } + } + } + + /// Fold the undo steps recorded since the project was `snap` into one step called `name` + /// (commands built from several commands). + pub fn collapse_undo(&mut self, name: &str, snap: &Arc) { + let h = &mut self.history; + let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { return }; + let first = h.undo[start].1.clone(); + h.undo.truncate(start); + h.undo.push((name.to_string(), first)); + h.merge_key = None; + } + + /// Cancel and drop every task (new/open project). + pub(crate) fn drop_tasks(&mut self) { + for t in &self.tasks { + t.ctl.cancel.store(true, Ordering::Relaxed); + } + for mut t in std::mem::take(&mut self.tasks) { + if let Some(th) = t.thread.take() { + let _ = th.join(); + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 508fb72e2..c8efb428c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,8 +13,10 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; +pub mod jobs; pub mod links; pub mod mask_track; +pub mod media_browser; pub mod menus; pub mod offload; pub mod prefs; @@ -43,6 +45,7 @@ pub use effectcraft_effects as effects; pub use effectcraft_geom as geom; pub use effectcraft_keyframe as keyframe; pub use effectcraft_project as project; +pub use effectcraft_raster as raster; pub use effectcraft_render as render; pub use effectcraft_text as text; pub use effectcraft_time as time; @@ -216,6 +219,15 @@ pub struct EditorState { /// Essential Graphics can expose). #[serde(default)] pub essential_solo: bool, + /// The Footage panel: the footage shown, its time and In/Out marks (source time). + #[serde(default)] + pub footage_panel: Option, + /// Media Browser: the folder shown and the favourites. + #[serde(default)] + pub media_browser: media_browser::BrowserState, + /// Content-Aware Fill panel settings. + #[serde(default)] + pub content_fill: commands::content_fill::FillSettings, } fn one_view() -> u8 { @@ -322,6 +334,11 @@ pub struct Session { pub offload: Option>, /// Analyses running in the [`Session::offload`] worker. pub offloaded: Vec, + /// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`]. + pub tasks: Vec, + /// Finished tasks, newest last (Progress panel, `jobs.list`). + pub job_log: Vec, + pub next_task_id: u64, } /// A script to run (see [`Session::script`]). @@ -378,6 +395,9 @@ impl Default for Session { script: None, offload: None, offloaded: vec![], + tasks: vec![], + job_log: vec![], + next_task_id: 1, } } } @@ -634,6 +654,7 @@ impl Session { self.stop_roto(); self.roto_job = None; self.roto_pending.clear(); + self.drop_tasks(); p.fix_next_id(); upgrade_effects(&mut p); self.project = Arc::new(p); @@ -743,4 +764,6 @@ pub fn text_families() -> Vec { #[cfg(test)] mod tests_paint; #[cfg(test)] +mod tests_panels; +#[cfg(test)] mod tests_roto; diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs new file mode 100644 index 000000000..afc7dc342 --- /dev/null +++ b/crates/engine/src/media_browser.rs @@ -0,0 +1,270 @@ +//! Media Browser (Window ▸ Media Browser): browse the local file system for footage, keep +//! favourite folders, read file metadata, and import (`file.import`). Desktop only: the web build +//! has no file system to browse ([`available`] is false there). +//! +//! Also the metadata the Metadata panel shows for project items and files (codec, size, rate, +//! duration, colour profile, dates). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use effectcraft_project::{Footage, FootageKind, Item, ItemKind, Project}; + +/// Media Browser state (in [`crate::EditorState`], serde for agents). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserState { + /// The folder shown (`None` = the home folder). + #[serde(default)] + pub folder: Option, + /// Favourite folders. + #[serde(default)] + pub favorites: Vec, + /// Show only importable files. + #[serde(default)] + pub importable_only: bool, +} + +/// Whether this build can browse files. +pub fn available() -> bool { + !cfg!(target_arch = "wasm32") +} + +/// One directory entry. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: u64, + /// Seconds since the Unix epoch. + pub modified: Option, + /// Footage kind guessed from the extension (`video`, `image`, `audio`, `data`, `model`, + /// `project`), `None` for other files and folders. + pub kind: Option<&'static str>, +} + +const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; +const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"]; +const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; +const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; +const MODEL: &[&str] = &["gltf", "glb", "obj"]; + +/// Kind of file from its extension. +pub fn kind_of(path: &str) -> Option<&'static str> { + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + let e = ext.as_str(); + if VIDEO.contains(&e) { + Some("video") + } else if IMAGE.contains(&e) { + Some("image") + } else if AUDIO.contains(&e) { + Some("audio") + } else if DATA.contains(&e) { + Some("data") + } else if MODEL.contains(&e) { + Some("model") + } else if matches!(e, "ecproj" | "ectemplate" | "json5") { + Some("project") + } else { + None + } +} + +/// The user's home folder (or `/`). +pub fn home() -> String { + std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/".into()) +} + +fn unix(t: std::io::Result) -> Option { + t.ok()?.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs()) +} + +/// List a folder: folders first, then files, by name (hidden entries skipped). +pub fn list(dir: &str, importable_only: bool) -> Result, String> { + if !available() { + return Err("the Media Browser needs the desktop app".into()); + } + let rd = std::fs::read_dir(dir).map_err(|e| format!("{dir}: {e}"))?; + let mut out: Vec = rd + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + return None; + } + let md = e.metadata().ok()?; + let path = e.path().to_string_lossy().to_string(); + let kind = if md.is_dir() { None } else { kind_of(&path) }; + if importable_only && !md.is_dir() && kind.is_none() { + return None; + } + Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind }) + }) + .collect(); + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) +} + +/// The parent folder of `dir`. +pub fn parent(dir: &str) -> Option { + std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) +} + +/// Format seconds since the epoch as an ISO 8601 UTC date-time. +pub fn iso_date(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + // Civil-from-days (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) +} + +/// File dates and size: (created, modified, size). +pub fn file_dates(path: &str) -> (Option, Option, Option) { + if !available() || path.is_empty() { + return (None, None, None); + } + match std::fs::metadata(path) { + Ok(md) => (unix(md.created()), unix(md.modified()), Some(md.len())), + Err(_) => (None, None, None), + } +} + +fn kind_name(k: FootageKind) -> &'static str { + match k { + FootageKind::Video => "video", + FootageKind::Still => "still", + FootageKind::Sequence => "sequence", + FootageKind::Audio => "audio", + FootageKind::Model => "model", + FootageKind::Data => "data", + } +} + +/// Metadata of footage (the fields the Metadata panel lists). +pub fn footage_metadata(f: &Footage) -> Value { + let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)); + json!({ + "path": f.path, + "kind": kind_name(f.kind), + "codec": f.codec, + "width": f.width, + "height": f.height, + "pixelAspect": f.pixel_aspect, + "frameRate": f.frame_rate.as_f64(), + "duration": f.duration.seconds(), + "frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None }, + "hasVideo": f.has_video, + "hasAudio": f.has_audio, + "alpha": format!("{:?}", f.alpha), + "colorProfile": f.color_profile.map(|c| format!("{c:?}")).unwrap_or_else(|| "sRGB (default)".into()), + "linearLight": f.linear_light, + "fields": format!("{:?}", f.fields), + "sequenceFiles": f.sequence.len(), + "dataRows": f.data.as_ref().map(|d| d.lines().count().saturating_sub(1)), + "created": created.map(iso_date), + "modified": modified.map(iso_date), + "fileSize": size, + "missing": f.missing, + }) +} + +/// Metadata of a project item. +pub fn item_metadata(p: &Project, it: &Item) -> Value { + let mut v = json!({ + "id": it.id.0, + "name": it.name, + "type": it.type_name(), + "comment": it.comment, + "label": format!("{:?}", it.label), + }); + let extra = match &it.kind { + ItemKind::Footage(f) => footage_metadata(f), + ItemKind::Comp(c) => json!({ + "width": c.width, + "height": c.height, + "pixelAspect": c.pixel_aspect, + "frameRate": c.frame_rate.as_f64(), + "duration": c.duration.seconds(), + "layers": c.layers.len(), + "workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], + }), + ItemKind::Solid(s) => json!({"width": s.width, "height": s.height, "color": s.color}), + ItemKind::Folder => json!({"items": p.items.values().filter(|i| i.parent == Some(it.id)).count()}), + }; + if let (Value::Object(a), Value::Object(b)) = (&mut v, extra) { + a.extend(b); + } + if let Some(px) = &it.proxy { + v["proxy"] = json!(px.footage.path); + } + v +} + +/// Project-level metadata. +pub fn project_metadata(p: &Project, path: Option<&str>) -> Value { + let st = &p.settings; + let count = |f: &dyn Fn(&ItemKind) -> bool| p.items.values().filter(|i| f(&i.kind)).count(); + json!({ + "path": path, + "comment": st.comment, + "bitDepth": st.bit_depth.label(), + "workingSpace": st.working_space.map(|c| format!("{c:?}")), + "linearize": st.linearize, + "timeDisplay": format!("{:?}", st.time_display), + "audioSampleRate": st.audio_sample_rate, + "items": p.items.len(), + "compositions": count(&|k| matches!(k, ItemKind::Comp(_))), + "footage": count(&|k| matches!(k, ItemKind::Footage(_))), + "solids": count(&|k| matches!(k, ItemKind::Solid(_))), + "folders": count(&|k| matches!(k, ItemKind::Folder)), + "renderQueue": p.render_queue.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lists_a_folder() { + let dir = std::env::temp_dir().join(format!("ec-mb-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Shots")).unwrap(); + std::fs::write(dir.join("b.png"), b"x").unwrap(); + std::fs::write(dir.join("A.mov"), b"xy").unwrap(); + std::fs::write(dir.join("notes.txt"), b"xyz").unwrap(); + std::fs::write(dir.join(".hidden"), b"").unwrap(); + let d = dir.to_string_lossy().to_string(); + let all = list(&d, false).unwrap(); + let names: Vec<&str> = all.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["Shots", "A.mov", "b.png", "notes.txt"]); + assert!(all[0].is_dir); + assert_eq!(all[1].kind, Some("video")); + assert_eq!(all[2].kind, Some("image")); + assert_eq!(all[1].size, 2); + let media = list(&d, true).unwrap(); + assert_eq!(media.len(), 3); + assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str())); + assert!(list(&dir.join("nope").to_string_lossy(), false).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn iso_dates() { + assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z"); + assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z"); + } +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0470625c0..2ae54aeec 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -942,19 +942,19 @@ Window Audio | window.panel {"panel":"audio"} | Cmd+4 Brushes | window.panel {"panel":"brushes"} | Cmd+9 Character | window.panel {"panel":"character"} | Cmd+6 - Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"} + Content-Aware Fill | window.panel {"panel":"contentAwareFill"} Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5 Essential Graphics | window.panel {"panel":"essentialGraphics"} Info | window.panel {"panel":"info"} | Cmd+2 - Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"} + Lumetri Scopes | window.panel {"panel":"lumetriScopes"} Mask Interpolation | window.panel {"panel":"maskInterpolation"} - Media Browser | window.unavailablePanel {"panel":"mediaBrowser"} - Metadata | window.unavailablePanel {"panel":"metadata"} + Media Browser | window.panel {"panel":"mediaBrowser"} + Metadata | window.panel {"panel":"metadata"} Motion Sketch | window.panel {"panel":"motionSketch"} Paint | window.panel {"panel":"paint"} | Cmd+8 Paragraph | window.panel {"panel":"paragraph"} | Cmd+7 Preview | window.panel {"panel":"preview"} | Cmd+3 - Progress | window.unavailablePanel {"panel":"progress"} + Progress | window.panel {"panel":"progress"} Properties | window.panel {"panel":"properties"} Script Console | window.panel {"panel":"scriptConsole"} Smoother | window.panel {"panel":"smoother"} @@ -965,7 +965,7 @@ Window Composition | window.panel {"panel":"composition"} Effect Controls | window.panel {"panel":"effectControls"} | F3 Flowchart | window.panel {"panel":"flowchart"} - Footage | window.unavailablePanel {"panel":"footage"} + Footage | window.panel {"panel":"footage"} Layer | window.panel {"panel":"layer"} Project | window.panel {"panel":"project"} | Cmd+0 Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0 diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs new file mode 100644 index 000000000..d989b28e2 --- /dev/null +++ b/crates/engine/src/tests_panels.rs @@ -0,0 +1,518 @@ +//! M13.6 panels and tools through the commands: Progress (jobs), Media Browser, Metadata, +//! Lumetri Scopes, the Footage panel's Overlay / Ripple Insert edits, Content-Aware Fill, Scene +//! Edit Detection, Auto-trace and Align Video to Data. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build}; +use effectcraft_raster::Image; +use effectcraft_raster::inpaint::psnr; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; + +const FPS: u32 = 25; + +/// Footage computed per frame. +struct Gen { + make: Box Image + Send + Sync>, + cache: Mutex>>, +} + +impl FootageSource for Gen { + fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + let f = (t.seconds() * FPS as f64).round().max(0.0) as u32; + let mut c = self.cache.lock().unwrap(); + Some(c.entry(f).or_insert_with(|| Arc::new((self.make)(f))).clone()) + } +} + +/// A comp `w × h`, `secs` long, with one footage layer drawn by `make` (returns session, comp, +/// layer, footage item). +fn setup(w: u32, h: u32, secs: f64, make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, ItemId, LayerId, ItemId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Panels", "width": w, "height": h, "frameRate": FPS, "duration": secs})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let (lid, item) = s + .edit("clip", None, |p, _| { + let footage = Footage { + path: "synthetic.mov".into(), + kind: FootageKind::Video, + width: w, + height: h, + frame_rate: FrameRate::new(FPS as i64, 1), + duration: Tick::from_seconds_f64(secs), + has_video: true, + codec: "SYN".into(), + ..Default::default() + }; + let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); + let comp = p.comp(cid).unwrap().clone(); + let l = build::layer(p, &comp, "Clip", LayerSource::Footage { item }, (w, h), None); + let id = l.id; + p.comp_mut(cid).unwrap().layers.insert(0, l); + Ok((id, item)) + }) + .unwrap(); + s.footage = Arc::new(Gen { make: Box::new(make), cache: Mutex::default() }); + s.execute("layer.select", json!({"layers": [lid.0]})).unwrap(); + (s, cid, lid, item) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ec-panels-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +// ---------------------------------------------------------------- Progress + +#[test] +fn progress_lists_and_cancels_jobs() { + let mut s = Session::default(); + let r = s + .spawn_task("test", "Endless job", false, |ctl| { + let mut k = 0; + while ctl.progress(k % 100, 100) { + std::thread::sleep(std::time::Duration::from_millis(2)); + k += 1; + } + Err(crate::render_queue::CANCELLED.into()) + }) + .unwrap(); + let id = r["job"].as_str().unwrap().to_string(); + let listed = s.execute("jobs.list", json!({})).unwrap(); + let running = listed["running"].as_array().unwrap(); + assert_eq!(running.len(), 1); + assert_eq!(running[0]["id"], json!(id)); + assert_eq!(running[0]["label"], json!("Endless job")); + assert_eq!(running[0]["cancellable"], json!(true)); + assert_eq!(s.execute("jobs.cancel", json!({"job": id})).unwrap()["cancelled"], json!(true)); + s.execute("jobs.wait", json!({})).unwrap(); + assert!(s.jobs().is_empty()); + assert_eq!(s.job_log.last().unwrap().status, "cancelled"); + // A finished job applies its edit (one undo step) and reports its result. + let v = s + .spawn_task("test", "Quick job", true, |ctl| { + ctl.progress(1, 1); + let apply: crate::jobs::Apply = Box::new(|s: &mut Session| { + s.edit("Quick", None, |p, _| { + p.settings.comment = "done".into(); + Ok(()) + })?; + Ok(json!(42)) + }); + Ok(apply) + }) + .unwrap(); + assert_eq!(v, json!(42)); + assert_eq!(s.project.settings.comment, "done"); + assert_eq!(s.job_log.last().unwrap().status, "done"); + assert!(s.undo()); + assert_eq!(s.project.settings.comment, ""); +} + +// ---------------------------------------------------------------- Media Browser & Metadata + +#[test] +fn media_browser_and_metadata() { + let dir = tmp("browser"); + std::fs::create_dir_all(dir.join("Footage")).unwrap(); + std::fs::write(dir.join("shot.mov"), b"0123").unwrap(); + std::fs::write(dir.join("data.csv"), "time,v\n0,1\n").unwrap(); + std::fs::write(dir.join("readme.txt"), "x").unwrap(); + let d = dir.to_string_lossy().to_string(); + let mut s = Session::default(); + let r = s.execute("mediaBrowser.go", json!({"path": d})).unwrap(); + let names: Vec<&str> = r["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Footage", "data.csv", "readme.txt", "shot.mov"]); + assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str())); + let r = s.execute("mediaBrowser.list", json!({"importableOnly": true})).unwrap(); + assert_eq!(r["entries"].as_array().unwrap().len(), 3); + s.execute("mediaBrowser.addFavorite", json!({})).unwrap(); + assert_eq!(s.state.media_browser.favorites, vec![d.clone()]); + s.execute("mediaBrowser.go", json!({"path": dir.join("Footage").to_string_lossy()})).unwrap(); + s.execute("mediaBrowser.go", json!({"path": ".."})).unwrap(); + assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str())); + s.execute("mediaBrowser.removeFavorite", json!({"path": d})).unwrap(); + assert!(s.state.media_browser.favorites.is_empty()); + // Import a data file from the browser; its metadata reads back. + let r = s.execute("mediaBrowser.import", json!({"paths": [dir.join("data.csv").to_string_lossy()]})).unwrap(); + let id = r["items"][0].as_u64().unwrap(); + let m = s.execute("item.metadata", json!({"item": id})).unwrap(); + assert_eq!(m["item"]["type"], json!("Data")); + assert_eq!(m["item"]["codec"], json!("CSV")); + assert_eq!(m["item"]["dataRows"], json!(1)); + assert!(m["item"]["modified"].as_str().unwrap().ends_with('Z')); + assert_eq!(m["project"]["footage"], json!(1)); + // Editable comments: item (undoable) and project. + s.execute("project.setComment", json!({"items": [id], "comment": "telemetry"})).unwrap(); + s.execute("project.setProjectComment", json!({"comment": "client cut"})).unwrap(); + let m = s.execute("item.metadata", json!({"item": id})).unwrap(); + assert_eq!(m["item"]["comment"], json!("telemetry")); + assert_eq!(m["project"]["comment"], json!("client cut")); + s.undo(); + assert_eq!(s.project.settings.comment, ""); + let fi = s.execute("mediaBrowser.fileInfo", json!({"path": dir.join("shot.mov").to_string_lossy()})).unwrap(); + assert_eq!(fi["size"], json!(4)); + assert_eq!(fi["kind"], json!("video")); + std::fs::remove_dir_all(&dir).unwrap(); +} + +// ---------------------------------------------------------------- Lumetri Scopes + +#[test] +fn scopes_read_the_comp_frame() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 48, "frameRate": 25, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let v = s.execute("scopes.analyze", json!({"scope": "vectorscopeYuv", "size": 101})).unwrap(); + let st = effectcraft_raster::scopes::ColorStandard::Rec709; + let [_, cb, cr] = st.ycbcr([1.0, 0.0, 0.0]); + assert_eq!(v["peaks"][0], json!([((cb + 0.5) * 100.0).round() as u32, ((0.5 - cr) * 100.0).round() as u32])); + let h = s.execute("scopes.analyze", json!({"scope": "histogram", "standard": "rec601"})).unwrap(); + let bars = &h["histogram"]; + assert_eq!(bars[0][255], json!(64 * 48)); + assert_eq!(bars[1][0], json!(64 * 48)); + assert!((h["luma"]["mean"].as_f64().unwrap() - 0.299).abs() < 1e-3); + assert!(s.execute("scopes.analyze", json!({"scope": "bogus"})).is_err()); +} + +// ---------------------------------------------------------------- Footage panel + +#[test] +fn footage_panel_overlay_and_ripple_insert() { + let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0])); + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert!( + s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "footage")) + ); + s.execute("footage.setTime", json!({"time": 1.0})).unwrap(); + s.execute("footage.setIn", json!({})).unwrap(); + s.execute("footage.setOut", json!({"frame": 49})).unwrap(); + let info = s.execute("footage.info", json!({})).unwrap(); + assert_eq!(info["in"], json!(1.0)); + assert_eq!(info["out"], json!(2.0)); + // Overlay Edit at 0.6 s: a 1 s layer on top showing source 1–2 s. + s.set_time(Tick::from_seconds_f64(0.6)); + let r = s.execute("footage.overlayEdit", json!({})).unwrap(); + let ov = LayerId(r["layer"].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(comp.layers[0].id, ov); + let l = comp.layer(ov).unwrap(); + assert_eq!((l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()), (0.6, 1.6, -0.4)); + assert_eq!(comp.layer(clip).unwrap().out_point.seconds(), 8.0, "overlay leaves other layers alone"); + // Ripple Insert at 3 s: layers crossing 3 s are split, the rest moves back by 1 s. + s.set_time(Tick::from_seconds_f64(3.0)); + let r = s.execute("footage.rippleInsertEdit", json!({})).unwrap(); + let ins = LayerId(r["layer"].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(comp.layers.len(), 4, "overlay, insert, clip split in two"); + let l = comp.layer(ins).unwrap(); + assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (3.0, 4.0)); + let head = comp.layer(clip).unwrap(); + assert_eq!((head.in_point.seconds(), head.out_point.seconds()), (0.0, 3.0)); + let tail = comp.layers.iter().find(|x| x.id != clip && x.id != ins && x.id != ov).unwrap(); + assert_eq!((tail.in_point.seconds(), tail.out_point.seconds(), tail.start_time.seconds()), (4.0, 9.0, 1.0)); + // Source time continuity: the tail shows source 3 s at comp 4 s. + assert_eq!(tail.layer_time(Tick::from_seconds_f64(4.0)).seconds(), 3.0); + // The overlay layer (in before 3 s, out before 3 s) didn't move. + assert_eq!(comp.layer(ov).unwrap().in_point.seconds(), 0.6); + // One undo step each. + assert!(s.undo()); + assert_eq!(s.project.comp(cid).unwrap().layers.len(), 2); + assert!(s.execute("footage.setOut", json!({"time": 0.5})).is_ok()); + assert_eq!(s.state.footage_panel.as_ref().unwrap().in_point, None, "an Out before the In clears the In"); +} + +// ---------------------------------------------------------------- Content-Aware Fill + +fn bg(x: f32, y: f32) -> [f32; 4] { + let s = 0.5 + 0.3 * (x * std::f32::consts::TAU / 10.0).sin(); + let n = effectcraft_raster::hash_noise(((x.floor() as i64).rem_euclid(20) / 4) as u32, ((y as u32) % 20) / 4, 3); + [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0] +} + +/// Panning textured background with a red "object" walking through the masked region. +fn caf_frame(f: u32, with_object: bool) -> Image { + let (w, h) = (96u32, 64u32); + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let mut p = bg(x as f32 + 2.0 * f as f32, y as f32); + let ox = 40 + (f % 4); + if with_object && (ox..ox + 10).contains(&x) && (26..36).contains(&y) { + p = [0.9, 0.1, 0.1, 1.0]; + } + img.set(x, y, p); + } + } + img +} + +#[test] +fn content_aware_fill_removes_an_object() { + let (mut s, cid, clip, _) = setup(96, 64, 0.48, |f| caf_frame(f, true)); + // Cut the object out with a Subtract mask; fill the work area (12 frames). + s.execute("layer.addMask", json!({"rect": [36.0, 22.0, 20.0, 18.0], "mode": "subtract"})).unwrap(); + let out = tmp("caf"); + s.execute("contentFill.set", json!({"method": "object", "range": "workArea", "alphaExpansion": 1})).unwrap(); + let r = s.execute("contentFill.generate", json!({"outputDir": out.to_string_lossy(), "wait": true})).unwrap(); + let fill = LayerId(r["layer"].as_u64().unwrap()); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 12); + // The Fill layer sits right above the source with the source's timing. + let comp = s.project.comp(cid).unwrap(); + let i = comp.layers.iter().position(|l| l.id == fill).unwrap(); + assert_eq!(comp.layers[i + 1].id, clip); + let fl = comp.layer(fill).unwrap(); + assert_eq!((fl.in_point, fl.out_point.seconds()), (Tick::ZERO, 0.48)); + let LayerSource::Footage { item } = fl.source else { panic!("footage") }; + let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { panic!() }; + assert_eq!((f.kind, f.sequence.len()), (FootageKind::Sequence, 12)); + assert!(s.project.item(item).unwrap().name.starts_with("Fill 1")); + // The filled frames match the clean background inside the mask, and stay steady over time. + let region: Vec = (0..96 * 64).map(|i| (36..56).contains(&(i % 96)) && (22..40).contains(&(i / 96))).collect(); + let mut prev: Option = None; + for (k, path) in files.iter().enumerate() { + let png = image::open(path).unwrap().to_rgba8(); + let img = Image::from_rgba8(96, 64, png.as_raw()); + let truth = caf_frame(k as u32, false); + let db = psnr(&img, &truth, Some(®ion)); + assert!(db > 25.0, "frame {k}: {db:.1} dB"); + // Outside the hole the frame is the source itself. + let outside: Vec = region.iter().map(|r| !r).collect(); + assert!(psnr(&img, &caf_frame(k as u32, true), Some(&outside)) > 40.0); + if let Some(p) = &prev { + // Temporal consistency: the change in the hole follows the 2 px/frame pan (compare + // against the previous fill shifted). + let mut shifted = p.clone(); + for y in 0..64 { + for x in 0..94 { + shifted.data[y * 96 + x] = p.data[y * 96 + x + 2]; + } + } + let inner: Vec = (0..96 * 64).map(|i| (38..54).contains(&(i % 96)) && (24..38).contains(&(i / 96))).collect(); + assert!(psnr(&img, &shifted, Some(&inner)) > 24.0, "frame {k} jumps"); + } + prev = Some(img); + } + // One undo step removes the fill layer. + assert!(s.undo()); + assert!(s.project.comp(cid).unwrap().layer(fill).is_none()); + std::fs::remove_dir_all(&out).unwrap(); + // Without a hole there is nothing to fill. + let (mut s2, _, _, _) = setup(32, 24, 0.2, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0])); + assert!(s2.execute("contentFill.generate", json!({"wait": true, "outputDir": tmp("caf2").to_string_lossy()})).is_err()); +} + +#[test] +fn content_aware_fill_runs_in_the_background() { + let (mut s, _, _, _) = setup(48, 32, 0.2, |f| { + let mut img = Image::new(48, 32); + for y in 0..32 { + for x in 0..48 { + img.set(x, y, bg(x as f32 + f as f32, y as f32)); + } + } + img + }); + s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap(); + let out = tmp("cafbg"); + let r = s.execute("contentFill.generate", json!({"method": "edgeBlend", "outputDir": out.to_string_lossy()})).unwrap(); + assert!(r["job"].as_str().unwrap().starts_with("task:")); + assert_eq!(s.jobs().len(), 1); + assert_eq!(s.jobs()[0].kind, "contentFill"); + s.wait_jobs(); + let rec = s.job_log.last().unwrap(); + assert_eq!(rec.status, "done", "{}", rec.message); + assert_eq!(rec.result["frames"], json!(5)); + std::fs::remove_dir_all(&out).unwrap(); +} + +// ---------------------------------------------------------------- Scene Edit Detection + +fn shot_frame(f: u32) -> Image { + // Shots: frames 0–9, 10–17, 18–24. + let (kind, k) = if f < 10 { + (0, f) + } else if f < 18 { + (1, f - 10) + } else { + (2, f - 18) + }; + let mut img = Image::new(80, 48); + for y in 0..48 { + for x in 0..80 { + let fx = x as f32 + 2.0 * k as f32; + let v = match kind { + 0 => 0.5 + 0.4 * (fx * 0.15).sin() * (y as f32 * 0.11).cos(), + 1 => { + if ((fx / 10.0).floor() as i32 + y as i32 / 10) % 2 == 0 { + 0.85 + } else { + 0.15 + } + } + _ => effectcraft_raster::hash_noise((fx.floor() as u32) / 4, y / 4, 5), + }; + let c = match kind { + 0 => [v, v * 0.6, 0.2], + 1 => [0.1, v * 0.8, v], + _ => [v * 0.9, v * 0.9, 0.5 + v * 0.4], + }; + img.set(x, y, [c[0], c[1], c[2], 1.0]); + } + } + img +} + +#[test] +fn scene_edit_detection_finds_cuts() { + let (mut s, cid, clip, _) = setup(80, 48, 1.0, shot_frame); + // Markers. + let r = s.execute("layer.sceneEditDetection", json!({"mode": "markers", "wait": true})).unwrap(); + assert_eq!(r["cuts"], json!([0.4, 0.72])); + let l = s.project.comp(cid).unwrap().layer(clip).unwrap(); + assert_eq!(l.markers.iter().map(|m| m.time.seconds()).collect::>(), vec![0.4, 0.72]); + s.undo(); + // Split Layers. + let r = s.execute("layer.sceneEditDetection", json!({"mode": "split", "wait": true})).unwrap(); + let ids: Vec = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 3); + let comp = s.project.comp(cid).unwrap(); + let spans: Vec<(f64, f64)> = ids.iter().map(|i| comp.layer(LayerId(*i)).unwrap()).map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect(); + assert_eq!(spans, vec![(0.0, 0.4), (0.4, 0.72), (0.72, 1.0)]); + s.undo(); + assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1); + // Split and Precompose: three precomp layers, one undo step. + let r = s.execute("layer.sceneEditDetection", json!({"mode": "splitPrecompose", "wait": true})).unwrap(); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(comp.layers.len(), 3); + for v in r["layers"].as_array().unwrap() { + let l = comp.layer(LayerId(v.as_u64().unwrap())).unwrap(); + assert!(matches!(l.source, LayerSource::Comp { .. })); + } + assert_eq!(s.history.undo.last().unwrap().0, "Scene Edit Detection"); + s.undo(); + assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1); +} + +// ---------------------------------------------------------------- Auto-trace + +fn disc_frame(f: u32) -> Image { + let mut img = Image::new(80, 60); + let (cx, cy) = (30.0 + 2.0 * f as f64, 30.0); + for y in 0..60 { + for x in 0..80 { + let d = ((x as f64 + 0.5 - cx).powi(2) + (y as f64 + 0.5 - cy).powi(2)).sqrt(); + // Anti-aliased disc of radius 15 plus a 12 × 12 square, on transparency. + let a = (15.5 - d).clamp(0.0, 1.0) as f32; + let sq = (60..72).contains(&x) && (8..20).contains(&y); + let a = if sq { 1.0 } else { a }; + img.set(x, y, [a, a, a, a]); + } + } + img +} + +fn iou_with(path_set: &[effectcraft_keyframe::ShapePath], truth: &Image) -> f64 { + let paths: Vec<_> = path_set.iter().map(effectcraft_path::to_kurbo).collect(); + let cov = effectcraft_path::fill_coverage(&paths, &effectcraft_geom::Mat3::IDENTITY, truth.width, truth.height, effectcraft_path::FillRule::EvenOdd); + let (mut i, mut u) = (0.0, 0.0); + for (c, p) in cov.data.iter().zip(&truth.data) { + let (a, b) = (*c >= 0.5, p[3] >= 0.5); + i += (a && b) as u8 as f64; + u += (a || b) as u8 as f64; + } + i / u +} + +fn mask_paths(s: &Session, cid: ItemId, lid: LayerId, lt: Tick) -> Vec { + let l = s.project.comp(cid).unwrap().layer(lid).unwrap(); + l.masks() + .unwrap() + .groups() + .filter(|g| g.get("opacity").unwrap().value_at(lt).as_f64() > 0.0) + .map(|g| match g.get("path").unwrap().value_at(lt) { + effectcraft_project::Value::Path(p) => p, + _ => panic!("path"), + }) + .collect() +} + +#[test] +fn auto_trace_matches_shapes() { + let (mut s, cid, clip, _) = setup(80, 60, 0.4, disc_frame); + let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "alpha", "tolerance": 0.5, "cornerRoundness": 30})).unwrap(); + assert_eq!(r["masks"], json!(2)); + let iou = iou_with(&mask_paths(&s, cid, clip, Tick::ZERO), &disc_frame(0)); + assert!(iou > 0.95, "IoU {iou}"); + s.undo(); + // Work area onto a new layer: one key per frame following the moving disc. + let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "timeSpan": "workArea", "applyToNewLayer": true, "minimumArea": 20})).unwrap(); + let nl = LayerId(r["layer"].as_u64().unwrap()); + assert_ne!(nl, clip); + assert_eq!(r["frames"], json!(10)); + for f in [0u32, 5, 9] { + let t = FrameRate::new(FPS as i64, 1).tick_of(f as i64); + let iou = iou_with(&mask_paths(&s, cid, nl, t), &disc_frame(f)); + assert!(iou > 0.93, "frame {f}: IoU {iou}"); + } + // Luminance with Invert traces the background instead (one outline with holes). + s.undo(); + let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "luminance", "invert": true, "threshold": 50})).unwrap(); + assert!(r["masks"].as_u64().unwrap() >= 1); +} + +// ---------------------------------------------------------------- Align Video to Data + +#[test] +fn align_video_to_data() { + let (mut s, cid, clip, _) = setup(16, 16, 20.0, |_| Image::filled(16, 16, [0.2, 0.2, 0.2, 1.0])); + let add_data = |s: &mut Session, name: &str, ext: &str, text: &str| -> u64 { + s.edit("data", None, |p, _| { + let f = + Footage { path: format!("{name}.{ext}"), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text.into()), ..Default::default() }; + Ok(p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f)).0) + }) + .unwrap() + }; + let gps = add_data(&mut s, "gps", "csv", "lat,lon,timestamp\n1,2,2026-05-01T10:00:00Z\n1,2,2026-05-01T10:00:01Z\n"); + // The video started 5 s after the first sample: it moves 5 s later. + let r = s.execute("layer.alignVideoToData", json!({"data": gps, "videoStart": "2026-05-01T10:00:05Z"})).unwrap(); + assert_eq!(r["startTime"], json!(5.0)); + let l = s.project.comp(cid).unwrap().layer(clip).unwrap(); + assert_eq!((l.start_time.seconds(), l.in_point.seconds()), (5.0, 5.0)); + s.undo(); + // Data placed at 2 s, timestamps as times of day, video start as timecode. + let log = add_data(&mut s, "log", "json", r#"{"samples": [{"t": "09:59:58.0", "v": 1}, {"t": "09:59:59.0", "v": 2}]}"#); + let r = s.execute("layer.alignVideoToData", json!({"data": "log", "videoStart": "10:00:00:00", "dataStart": 2.0})).unwrap(); + assert_eq!(r["key"], json!("t")); + assert_eq!(r["startTime"], json!(4.0)); + // A video that started before the data moves earlier (negative start). + let r = s.execute("layer.alignVideoToData", json!({"data": log, "videoStart": "2026-05-01T09:59:57Z"})).unwrap(); + assert_eq!(r["startTime"], json!(-1.0)); + // Errors: unknown key, no data. + assert!(s.execute("layer.alignVideoToData", json!({"data": gps, "key": "nope", "videoStart": 0})).is_err()); + let _: Value = Value::Null; +} + +#[test] +fn panels_commands_are_wired() { + // The Window menu opens real panels now, and the former stubs are enabled. + let menus = crate::menus::entries(); + for panel in ["lumetriScopes", "footage", "mediaBrowser", "metadata", "progress", "contentAwareFill"] { + assert!(menus.iter().any(|(_, e)| e.command == "window.panel" && e.params["panel"] == panel), "{panel}"); + } + assert!(crate::find_command("window.unavailablePanel").is_none()); + let (s, _, _, _) = setup(8, 8, 1.0, |_| Image::filled(8, 8, [0.0; 4])); + for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.alignVideoToData", "layer.newContentAwareFill", "contentFill.generate"] { + assert!(s.is_enabled(id), "{id}"); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e9a641e8a..824bd929d 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -118,6 +118,9 @@ pub struct ProjectSettings { /// when a GPU adapter exists) or Mercury Software Only (`false`, the CPU compositor). #[serde(default = "yes")] pub gpu_acceleration: bool, + /// The project's comment (Metadata panel). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, } impl Default for ProjectSettings { @@ -131,6 +134,7 @@ impl Default for ProjectSettings { frame_start: 0, audio_sample_rate: 48_000, gpu_acceleration: true, + comment: String::new(), } } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 91d7c335c..298f66527 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -36,10 +36,16 @@ pub enum PanelKind { MaskInterpolation, ScriptConsole, EssentialGraphics, + LumetriScopes, + Footage, + MediaBrowser, + Metadata, + Progress, + ContentAwareFill, } impl PanelKind { - pub const ALL: [PanelKind; 26] = [ + pub const ALL: [PanelKind; 32] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -66,6 +72,12 @@ impl PanelKind { PanelKind::MaskInterpolation, PanelKind::ScriptConsole, PanelKind::EssentialGraphics, + PanelKind::LumetriScopes, + PanelKind::Footage, + PanelKind::MediaBrowser, + PanelKind::Metadata, + PanelKind::Progress, + PanelKind::ContentAwareFill, ]; pub fn title(self) -> &'static str { match self { @@ -95,6 +107,12 @@ impl PanelKind { PanelKind::MaskInterpolation => "Mask Interpolation", PanelKind::ScriptConsole => "Script Console", PanelKind::EssentialGraphics => "Essential Graphics", + PanelKind::LumetriScopes => "Lumetri Scopes", + PanelKind::Footage => "Footage", + PanelKind::MediaBrowser => "Media Browser", + PanelKind::Metadata => "Metadata", + PanelKind::Progress => "Progress", + PanelKind::ContentAwareFill => "Content-Aware Fill", } } pub fn id(self) -> String { @@ -102,7 +120,7 @@ impl PanelKind { } pub fn from_name(s: &str) -> Option { let n = s.to_ascii_lowercase().replace([' ', '_', '-', '&'], ""); - Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&'], "") == n) + Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&', '-'], "") == n) } /// Window-menu shortcut. pub fn window_shortcut(self) -> Option<&'static str> { @@ -295,7 +313,7 @@ pub fn workspace(name: &str) -> DockNode { hsplit( FixedA(360.0), tabs(&[EffectControls, Project], 0), - hsplit(FixedB(300.0), tabs(&[Composition, Layer], 0), right(&[Info, Preview], &[EffectsPresets])), + hsplit(FixedB(340.0), tabs(&[Composition, Layer], 0), right(&[LumetriScopes, Info, Preview], &[EffectsPresets])), ), tabs(&[Timeline], 0), ), @@ -315,17 +333,17 @@ pub fn workspace(name: &str) -> DockNode { Ratio(0.55), hsplit( FixedA(300.0), - tabs(&[Project, EffectControls, Flowchart, History], 0), + tabs(&[Project, EffectControls, Flowchart, History, MediaBrowser, Metadata], 0), hsplit( FixedB(300.0), - tabs(&[Composition, Layer], 0), + tabs(&[Composition, Layer, Footage], 0), right( - &[Info, Preview, Audio, Align, Character, Paragraph], - &[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers], + &[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes], + &[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill], ), ), ), - tabs(&[Timeline, RenderQueue], 0), + tabs(&[Timeline, RenderQueue, Progress], 0), ), "Standard" => vsplit( Ratio(0.56), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 912f2e687..9df1d6ee7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -787,6 +787,11 @@ impl EffectcraftApp { self.session.poll_mask_track(); ctx.request_repaint_after(std::time::Duration::from_millis(50)); } + // Background tasks (Content-Aware Fill, Scene Edit Detection). + if !self.session.tasks.is_empty() { + self.session.poll_jobs(); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } // Analyses running in a worker (the web app). if !self.session.offloaded.is_empty() { self.session.poll_offload(); diff --git a/crates/ui-egui/src/panels/content_fill_panel.rs b/crates/ui-egui/src/panels/content_fill_panel.rs new file mode 100644 index 000000000..5d534e595 --- /dev/null +++ b/crates/ui-egui/src/panels/content_fill_panel.rs @@ -0,0 +1,138 @@ +//! Content-Aware Fill panel (Window ▸ Content-Aware Fill): Fill Target, Alpha Expansion, Fill +//! Method (Object / Surface / Edge Blend), Lighting Correction, Range (Work Area / Entire +//! Duration), a reference frame from a layer of the comp, and Generate Fill Layer. The settings +//! live in `EditorState::content_fill` (`contentFill.set`); the button runs +//! `contentFill.generate` as a background job (Window ▸ Progress). + +use egui::{Align2, Rect, pos2, vec2}; +use serde_json::json; + +use super::panel_kit as kit; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +const ROW: f32 = 28.0; + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + let x0 = rect.min.x + 12.0; + let xv = x0 + 130.0; + let w = (rect.max.x - xv - 12.0).clamp(90.0, 220.0); + let mut y = rect.min.y + 10.0; + let st = app.session.state.content_fill.clone(); + let comp = app.session.active_comp().cloned(); + let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|l| c.layer(*l)).cloned()); + // Fill Target. + p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, "Fill Target", Tokens::semibold(12.0), t.text); + y += ROW - 6.0; + let target = match &layer { + Some(l) => { + let masks = l + .masks() + .map(|m| { + m.groups() + .filter(|g| { + matches!( + g.kind, + effectcraft_engine::project::GroupKind::Mask { mode: effectcraft_engine::project::MaskMode::Subtract, .. } + | effectcraft_engine::project::GroupKind::Mask { inverted: true, .. } + ) + }) + .count() + }) + .unwrap_or(0); + format!("{} — {masks} subtracting mask(s)", l.name) + } + None => "Select a layer with transparent areas (e.g. a Subtract mask)".into(), + }; + p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, &target, Tokens::ui(11.5), if layer.is_some() { t.text_dim } else { t.text_faint }); + app.auto.add("contentFill.target", Rect::from_min_size(pos2(x0, y), vec2(rect.width() - 24.0, 18.0)), &target); + y += ROW; + let mut set = serde_json::Map::new(); + // Alpha Expansion. + kit::label(ui, pos2(x0, y), "Alpha Expansion:", &t); + let a = kit::number(app, ui, pos2(xv, y), "contentFill.alphaExpansion", st.alpha_expansion, 0.2, (0.0, 100.0), 0, " px"); + if (a - st.alpha_expansion).abs() > 1e-9 { + set.insert("alphaExpansion".into(), json!(a.round())); + } + y += ROW; + // Fill Method. + kit::label(ui, pos2(x0, y), "Fill Method:", &t); + let methods = ["object", "surface", "edgeBlend"]; + let cur = methods.iter().position(|m| *m == st.method).unwrap_or(0); + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.method", &["Object", "Surface", "Edge Blend"], cur) { + set.insert("method".into(), json!(methods[i])); + } + y += ROW; + // Lighting Correction (Object / Surface). + kit::label(ui, pos2(x0, y), "Lighting Correction:", &t); + let lights = ["none", "subtle", "moderate", "strong"]; + let cur = lights.iter().position(|m| *m == st.lighting_correction).unwrap_or(0); + if st.method != "edgeBlend" + && let Some(i) = + kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.lighting", &["None", "Subtle", "Moderate", "Strong"], cur) + { + set.insert("lightingCorrection".into(), json!(lights[i])); + } + y += ROW; + // Range. + kit::label(ui, pos2(x0, y), "Range:", &t); + let cur = usize::from(st.range == "entire"); + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.range", &["Work Area", "Entire Duration"], cur) { + set.insert("range".into(), json!(if i == 1 { "entire" } else { "workArea" })); + } + y += ROW; + // Reference frame: a layer of the comp at a time. + kit::label(ui, pos2(x0, y), "Reference Frame:", &t); + if let Some(c) = &comp { + let mut names = vec!["None".to_string()]; + names.extend(c.layers.iter().map(|l| l.name.clone())); + let cur = st.reference_layer.and_then(|r| c.layers.iter().position(|l| l.id == r)).map(|i| i + 1).unwrap_or(0); + let opts: Vec<&str> = names.iter().map(String::as_str).collect(); + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.referenceLayer", &opts, cur) { + set.insert("referenceLayer".into(), if i == 0 { json!(null) } else { json!(c.layers[i - 1].id.0) }); + } + y += ROW; + if st.reference_layer.is_some() { + let at = st.reference_time.unwrap_or(0.0); + p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, format!("at {}", kit::seconds(at)), Tokens::ui(11.5), t.text_dim); + if kit::button(app, ui, Rect::from_min_size(pos2(xv + 80.0, y), vec2(120.0, 20.0)), "contentFill.referenceHere", "Use Current Time", false) { + set.insert("referenceTime".into(), json!(app.session.time().seconds())); + } + y += ROW; + } + } else { + y += ROW; + } + if !set.is_empty() { + kit::exec(app, "contentFill.set", serde_json::Value::Object(set)); + } + // Generate, or the running job. + y += 6.0; + let running = app.session.jobs().into_iter().find(|j| j.kind == "contentFill"); + match running { + Some(j) => { + let frac = j.fraction.unwrap_or(0.0) as f32; + let bar = Rect::from_min_size(pos2(x0, y + 7.0), vec2((rect.width() - 110.0).max(60.0), 6.0)); + p.rect_filled(bar, 3.0, t.field_bg); + p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac, 6.0)), 3.0, t.accent); + p.text(pos2(x0, y + 24.0), Align2::LEFT_CENTER, format!("{} {:.0} %", j.message, frac * 100.0), Tokens::ui(11.0), t.text_dim); + if kit::button(app, ui, Rect::from_min_size(pos2(bar.max.x + 10.0, y), vec2(70.0, 20.0)), "contentFill.cancel", "Cancel", false) { + kit::exec(app, "jobs.cancel", json!({"job": j.id})); + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(150)); + } + None => { + if kit::button(app, ui, Rect::from_min_size(pos2(x0, y), vec2(160.0, 24.0)), "contentFill.generate", "Generate Fill Layer", true) { + match &layer { + Some(l) => { + kit::exec(app, "contentFill.generate", json!({"layer": l.id.0})); + } + None => app.ui.status = "Select the layer to fill first".into(), + } + } + } + } +} diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs new file mode 100644 index 000000000..cfc698255 --- /dev/null +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -0,0 +1,150 @@ +//! The Footage panel (double-click footage in the Project panel): the source on its own time +//! ruler, play / pause, In and Out marks, and the Edit Target buttons — Overlay Edit and Ripple +//! Insert Edit — that cut the marked range into the active composition at its current time. +//! Everything runs `footage.*` commands (state in `EditorState::footage_panel`). + +use effectcraft_engine::project::{FootageKind, ItemKind}; +use effectcraft_engine::time::Tick; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use super::panel_kit as kit; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.pasteboard); + let Some(view) = app.session.state.footage_panel.clone() else { + p.text(rect.center(), Align2::CENTER_CENTER, "Double-click footage in the Project panel to open it here", Tokens::ui(12.0), t.text_faint); + app.auto.add("footage.empty", rect, "Footage panel (empty)"); + return; + }; + let Some(item) = app.session.project.item(view.item).cloned() else { + app.session.state.footage_panel = None; + return; + }; + let footage = match &item.kind { + ItemKind::Footage(f) => Some(f.clone()), + _ => None, + }; + let (dur, fr) = match &footage { + Some(f) if f.kind != FootageKind::Still && f.duration > Tick::ZERO => (f.duration, f.frame_rate), + Some(f) => (Tick::from_seconds_f64(10.0), f.frame_rate), + None => (Tick::from_seconds_f64(10.0), effectcraft_engine::time::FrameRate::FPS_30), + }; + // Title strip. + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); + p.rect_filled(top, 0.0, t.panel_bg); + p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, format!("Footage: {}", item.name), Tokens::semibold(12.0), t.text); + app.auto.add("footage.title", top, &item.name); + // Bottom: ruler and controls. + // Rows, bottom up: Edit Target buttons, transport, ruler. + let edit_row = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max); + let bar = Rect::from_min_max(pos2(rect.min.x, edit_row.min.y - 28.0), pos2(rect.max.x, edit_row.min.y)); + let ruler = Rect::from_min_max(pos2(rect.min.x + 10.0, bar.min.y - 26.0), pos2(rect.max.x - 10.0, bar.min.y - 4.0)); + p.rect_filled(Rect::from_min_max(pos2(rect.min.x, ruler.min.y - 4.0), rect.max), 0.0, t.panel_bg); + // Canvas. + let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, ruler.min.y - 4.0)); + if let Some(f) = &footage + && f.has_video + { + let key = (view.item.0, view.time.0, app.session.revision); + let tid = egui::Id::new("footage-panel-tex"); + let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); + let tex = match cached { + Some((k, tex, size)) if k == key => Some((tex, size)), + _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); + let tex = ctx.load_texture("footage-panel", ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); + (tex, [img.width, img.height]) + }), + }; + if let Some((tex, [w, h])) = tex { + let s = ((area.width() - 20.0) / w as f32).min((area.height() - 20.0) / h as f32).max(0.01); + let r = Rect::from_center_size(area.center(), vec2(w as f32 * s, h as f32 * s)); + super::viewer::checker(&p.with_clip_rect(area), r); + p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } else if let ItemKind::Solid(so) = &item.kind { + let c = so.color; + p.rect_filled(area.shrink(20.0), 0.0, Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + } else { + p.text(area.center(), Align2::CENTER_CENTER, "No video", Tokens::ui(12.0), t.text_faint); + } + // Ruler: ticks, In/Out span, CTI; click or drag to scrub. + let x_of = |tt: Tick| ruler.min.x + (tt.seconds() / dur.seconds().max(1e-9)) as f32 * ruler.width(); + p.rect_filled(ruler, 2.0, t.tl_ruler_bg); + let secs = dur.seconds().max(0.001); + let step = [0.04, 0.2, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0].into_iter().find(|s| (secs / s) as f32 * 50.0 < ruler.width()).unwrap_or(600.0); + let mut k = 0.0; + while k <= secs { + let x = ruler.min.x + (k / secs) as f32 * ruler.width(); + p.line_segment([pos2(x, ruler.max.y - 6.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); + p.text(pos2(x + 2.0, ruler.min.y + 1.0), Align2::LEFT_TOP, kit::seconds(k), Tokens::ui(9.0), t.tl_ruler_text); + k += step; + } + let (a, b) = (view.in_point.unwrap_or(Tick::ZERO), view.out_point.unwrap_or(dur)); + p.rect_filled(Rect::from_min_max(pos2(x_of(a), ruler.max.y - 4.0), pos2(x_of(b), ruler.max.y)), 0.0, t.work_area); + let cx = x_of(view.time); + p.line_segment([pos2(cx, ruler.min.y), pos2(cx, ruler.max.y)], Stroke::new(1.5, t.cti)); + let resp = ui.interact(ruler, egui::Id::new("footage-ruler"), Sense::click_and_drag()); + app.auto.add("footage.ruler", ruler, "Time ruler"); + if (resp.clicked() || resp.dragged()) + && let Some(pt) = resp.interact_pointer_pos() + { + let s = ((pt.x - ruler.min.x) / ruler.width()).clamp(0.0, 1.0) as f64 * dur.seconds(); + kit::exec(app, "footage.setTime", json!({"time": s})); + } + // Playback. + let play_id = egui::Id::new("footage-playing"); + let playing: bool = ctx.data(|d| d.get_temp(play_id).unwrap_or(false)); + if playing { + let dt = ctx.input(|i| i.stable_dt).min(0.1) as f64; + let next = view.time.seconds() + dt; + let next = if next >= dur.seconds() - fr.frame_duration().seconds() { 0.0 } else { next }; + let _ = app.session.execute("footage.setTime", json!({"time": next})); + ctx.request_repaint(); + } + // Controls. + let y = bar.min.y + 5.0; + let mut x = bar.min.x + 10.0; + p.text( + pos2(x, bar.center().y), + Align2::LEFT_CENTER, + effectcraft_engine::time::format_timecode_ae(fr.frame_at(view.time), fr, false), + Tokens::mono(12.0), + t.timecode, + ); + x += 92.0; + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(54.0, 20.0)), "footage.play", if playing { "Pause" } else { "Play" }, false) { + ctx.data_mut(|d| d.insert_temp(play_id, !playing)); + } + x += 60.0; + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(28.0, 20.0)), "footage.setIn", "{", false) { + kit::exec(app, "footage.setIn", json!({})); + } + x += 32.0; + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(28.0, 20.0)), "footage.setOut", "}", false) { + kit::exec(app, "footage.setOut", json!({})); + } + x += 36.0; + let span = format!("Δ {}", kit::seconds((b - a).seconds())); + p.text(pos2(x, bar.center().y), Align2::LEFT_CENTER, span, Tokens::ui(11.0), t.text_dim); + let comp_ok = app.session.active_comp_id().is_some(); + let target = app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default(); + let ey = edit_row.min.y + 5.0; + let bx = (edit_row.max.x - 238.0).max(edit_row.min.x + 10.0); + if !target.is_empty() && bx > edit_row.min.x + 140.0 { + p.text(pos2(bx - 8.0, edit_row.center().y), Align2::RIGHT_CENTER, format!("Edit Target: {target}"), Tokens::ui(11.0), t.text_dim); + } + if kit::button(app, ui, Rect::from_min_size(pos2(bx, ey), vec2(110.0, 20.0)), "footage.rippleInsertEdit", "Ripple Insert Edit", false) && comp_ok { + kit::exec(app, "footage.rippleInsertEdit", json!({})); + } + if kit::button(app, ui, Rect::from_min_size(pos2(bx + 116.0, ey), vec2(110.0, 20.0)), "footage.overlayEdit", "Overlay Edit", true) && comp_ok { + kit::exec(app, "footage.overlayEdit", json!({})); + } +} diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 3e0519481..8d63d1705 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -343,6 +343,60 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ) } + "layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => ( + "Auto-trace".into(), + vec![ + Field::choice("timeSpan", "Time Span", &[("Current Frame", json!("currentFrame")), ("Work Area", json!("workArea"))], 0), + Field::choice( + "channel", + "Channel", + &[("Alpha", json!("alpha")), ("Red", json!("red")), ("Green", json!("green")), ("Blue", json!("blue")), ("Luminance", json!("luminance"))], + 0, + ), + Field::bool("invert", "Invert", false), + Field::num("blur", "Blur (pixels before auto-trace)", 1.0), + Field::num("tolerance", "Tolerance (pixels)", 1.0), + Field::num("threshold", "Threshold (%)", 50.0), + Field::num("minimumArea", "Minimum Area (pixels)", 10.0), + Field::num("cornerRoundness", "Corner Roundness (%)", 50.0), + Field::bool("applyToNewLayer", "Apply To New Layer", false), + ], + ), + "layer.sceneEditDetection" if !has(p, &["mode"]) => ( + "Scene Edit Detection".into(), + vec![ + Field::choice( + "mode", + "Action", + &[("Create Markers", json!("markers")), ("Split Layers", json!("split")), ("Split and Precompose", json!("splitPrecompose"))], + 0, + ), + Field::num("threshold", "Sensitivity threshold (0–1, lower finds more)", 0.25), + ], + ), + "layer.alignVideoToData" if !has(p, &["data", "videoStart"]) => { + let data: Vec<(String, Value)> = s + .project + .items + .values() + .filter(|i| matches!(&i.kind, effectcraft_engine::project::ItemKind::Footage(f) if f.kind == effectcraft_engine::project::FootageKind::Data)) + .map(|i| (i.name.clone(), json!(i.id.0))) + .collect(); + if data.is_empty() { + info(app, "Align Video to Data", "Import a data file (JSON, CSV or TSV with a time column) first: File ▸ Import."); + return true; + } + let opts: Vec<(&str, Value)> = data.iter().map(|(n, v)| (n.as_str(), v.clone())).collect(); + ( + "Align Video to Data".into(), + vec![ + Field::choice("data", "Data", &opts, 0), + Field::text("key", "Time field (empty: automatic)", ""), + Field::text("videoStart", "Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", ""), + Field::num("dataStart", "First sample at (comp seconds)", 0.0), + ], + ) + } "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => ( "New Placeholder".into(), vec![ diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs new file mode 100644 index 000000000..b1a3230eb --- /dev/null +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -0,0 +1,366 @@ +//! Media Browser, Metadata and Progress panels (Window menu). +//! +//! - **Media Browser**: favourites and the folder tree on the left, the folder's files on the +//! right with thumbnails (images and video, decoded through the media layer's probe), double- +//! click to open a folder or import a file, drag files into the Project panel or the timeline. +//! Desktop only (the web build has no file system to browse). +//! - **Metadata**: what we read from the selected item's file (codec, size, rate, duration, +//! colour profile, dates) and the project's, with editable item and project comments. +//! - **Progress**: every background job (render queue, track / mask track / Warp Stabilizer / +//! 3D Camera Tracker / Roto Brush analyses, Content-Aware Fill, Scene Edit Detection) with a +//! progress bar and a cancel button, and the recently finished ones. + +use effectcraft_engine::media_browser as mb; +use effectcraft_engine::project::ItemId; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; + +use super::DragPayload; +use super::panel_kit as kit; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +// ---------------------------------------------------------------- Media Browser + +type Thumb = Option<(egui::TextureHandle, [u32; 2])>; + +/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable). +fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb { + let id = egui::Id::new(("mb-thumb", path)); + if let Some(t) = ctx.data(|d| d.get_temp::(id)) { + return t; + } + if *budget == 0 { + return None; + } + *budget -= 1; + let thumb = (|| { + let imp = app.session.importer.clone()?; + let f = imp.probe(path).ok()?; + if !f.has_video { + return None; + } + // A synthetic item id keyed by the path (the frame cache keys on the path). + let hid = path.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)) | (1 << 62); + let img = app.session.footage.frame(ItemId(hid), &f, effectcraft_engine::time::Tick::ZERO)?; + let step = (img.width.max(img.height) as f32 / 128.0).ceil().max(1.0) as u32; + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let full = img.to_rgba8(); + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let i = (((y * step) * img.width + x * step) * 4) as usize; + px.extend_from_slice(&full[i..i + 4]); + } + } + let tex = ctx.load_texture(format!("mb-{path}"), egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px), egui::TextureOptions::LINEAR); + Some((tex, [w, h])) + })(); + ctx.data_mut(|d| d.insert_temp(id, thumb.clone())); + thumb +} + +pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + if !mb::available() { + p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser needs the desktop app (use File ▸ Import here)", Tokens::ui(12.0), t.text_faint); + app.auto.add("mediaBrowser.unavailable", rect, "Media Browser unavailable"); + return; + } + let st = app.session.state.media_browser.clone(); + let dir = st.folder.clone().unwrap_or_else(mb::home); + // Listing cached per folder for a second. + let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only)); + let now = ctx.input(|i| i.time); + let listing: Option<(f64, Result, String>)> = ctx.data(|d| d.get_temp(lid)); + let entries = match listing { + Some((at, l)) if now - at < 1.0 => l, + _ => { + let l = mb::list(&dir, st.importable_only); + ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone()))); + l + } + }; + // Toolbar: up, home, path, favourite, importable only. + let y = rect.min.y + 6.0; + let mut x = rect.min.x + 8.0; + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(30.0, 20.0)), "mediaBrowser.up", "↑", false) { + kit::exec(app, "mediaBrowser.go", json!({"path": ".."})); + } + x += 34.0; + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(48.0, 20.0)), "mediaBrowser.home", "Home", false) { + kit::exec(app, "mediaBrowser.go", json!({})); + } + x += 54.0; + let fav = st.favorites.contains(&dir); + if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(24.0, 20.0)), "mediaBrowser.favorite", if fav { "★" } else { "☆" }, false) { + kit::exec(app, if fav { "mediaBrowser.removeFavorite" } else { "mediaBrowser.addFavorite" }, json!({"path": dir})); + } + x += 30.0; + let only = kit::checkbox(app, ui, pos2(x, y + 2.0), "mediaBrowser.importableOnly", "Media only", st.importable_only); + if only != st.importable_only { + kit::exec(app, "mediaBrowser.go", json!({"path": dir, "importableOnly": only})); + } + x += 100.0; + p.text(pos2(x, y + 10.0), Align2::LEFT_CENTER, &dir, Tokens::ui(11.5), t.text_dim); + app.auto.add("mediaBrowser.path", Rect::from_min_max(pos2(x, y), pos2(rect.max.x - 8.0, y + 20.0)), &dir); + // Left column: favourites. + let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 32.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y)); + p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator)); + p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, "Favorites", Tokens::semibold(11.5), t.text_dim); + let mut fy = side.min.y + 22.0; + let mut places: Vec<(String, String)> = vec![("Home".into(), mb::home())]; + places.extend( + st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())), + ); + for (i, (name, path)) in places.iter().enumerate() { + let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0)); + let resp = ui.interact(r, egui::Id::new(("mb-fav", i)), Sense::click()); + if *path == dir { + p.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + p.rect_filled(r, 0.0, t.hover); + } + p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + app.auto.add(&format!("mediaBrowser.place.{i}"), r, name); + if resp.clicked() { + kit::exec(app, "mediaBrowser.go", json!({"path": path})); + } + fy += 20.0; + } + // Files. + let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max); + let entries = match entries { + Ok(e) => e, + Err(e) => { + p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger); + return; + } + }; + let tile = vec2(140.0, 112.0); + let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize; + let mut budget = 2u32; + let scroll_id = egui::Id::new("mb-scroll"); + let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); + if ui.rect_contains_pointer(list) { + scroll -= ctx.input(|i| i.smooth_scroll_delta.y); + } + let rows = entries.len().div_ceil(cols); + scroll = scroll.clamp(0.0, (rows as f32 * tile.y - list.height() + 8.0).max(0.0)); + ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + let lp = p.with_clip_rect(list); + let mut actions: Vec<(&str, Value)> = vec![]; + for (i, e) in entries.iter().enumerate() { + let r = Rect::from_min_size( + pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll), + tile - vec2(8.0, 8.0), + ); + if !r.intersects(list) { + continue; + } + let resp = ui.interact(r.intersect(list), egui::Id::new(("mb-entry", &e.path)), Sense::click_and_drag()); + app.auto.add(&format!("mediaBrowser.entry.{}", e.name), r, &e.name); + lp.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.row }); + let thumb = Rect::from_min_max(r.min + vec2(6.0, 6.0), pos2(r.max.x - 6.0, r.max.y - 26.0)); + lp.rect_filled(thumb, 3.0, Color32::from_rgb(0x18, 0x18, 0x18)); + let kind = if e.is_dir { "Folder" } else { e.kind.unwrap_or("File") }; + match (e.is_dir, e.kind) { + (false, Some("image" | "video")) => match thumbnail(app, &ctx, &e.path, &mut budget) { + Some((tex, [w, h])) => { + let s = (thumb.width() / w as f32).min(thumb.height() / h as f32); + lp.image( + tex.id(), + Rect::from_center_size(thumb.center(), vec2(w as f32 * s, h as f32 * s)), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + Color32::WHITE, + ); + } + None => { + lp.text(thumb.center(), Align2::CENTER_CENTER, kind, Tokens::ui(11.0), t.text_faint); + } + }, + _ => { + lp.text(thumb.center(), Align2::CENTER_CENTER, if e.is_dir { "▣" } else { kind }, Tokens::ui(if e.is_dir { 28.0 } else { 11.0 }), t.text_faint); + } + } + let name = if e.name.chars().count() > 20 { format!("{}…", e.name.chars().take(19).collect::()) } else { e.name.clone() }; + lp.text(pos2(r.min.x + 8.0, r.max.y - 12.0), Align2::LEFT_CENTER, name, Tokens::ui(11.5), t.text); + if resp.double_clicked() { + if e.is_dir { + actions.push(("mediaBrowser.go", json!({"path": e.path}))); + } else if e.kind.is_some() { + actions.push(("mediaBrowser.import", json!({"paths": [e.path]}))); + } + } + if resp.drag_started() && !e.is_dir && e.kind.is_some() { + egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()])); + } + resp.context_menu(|ui| { + if !e.is_dir && ui.button("Import").clicked() { + actions.push(("mediaBrowser.import", json!({"paths": [e.path]}))); + ui.close(); + } + if !e.is_dir && ui.button("Import and Add to Composition").clicked() { + actions.push(("mediaBrowser.import", json!({"paths": [e.path], "addToComp": true}))); + ui.close(); + } + if e.is_dir && ui.button("Add to Favorites").clicked() { + actions.push(("mediaBrowser.addFavorite", json!({"path": e.path}))); + ui.close(); + } + }); + } + if entries.is_empty() { + lp.text(list.center(), Align2::CENTER_CENTER, "Empty folder", Tokens::ui(12.0), t.text_faint); + } + if budget == 0 { + ctx.request_repaint(); + } + for (id, p) in actions { + kit::exec(app, id, p); + } +} + +// ---------------------------------------------------------------- Metadata + +fn rows_of(v: &Value) -> Vec<(String, String)> { + let Some(o) = v.as_object() else { return vec![] }; + o.iter() + .filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "comment" | "id")) + .map(|(k, v)| { + let s = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.as_f64().map(|f| if f.fract() == 0.0 { format!("{f:.0}") } else { format!("{f:.3}") }).unwrap_or_default(), + other => other.to_string(), + }; + (k.clone(), s) + }) + .collect() +} + +/// An editable comment field; returns the edited text when it changes. +fn comment_field(ui: &mut egui::Ui, r: Rect, id: &str, text: &str) -> Option { + let key = egui::Id::new(("meta-comment", id)); + let mut buf: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| text.to_string()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let resp = child.add(egui::TextEdit::multiline(&mut buf).desired_width(r.width()).desired_rows(2).hint_text("Add a comment")); + if resp.has_focus() || resp.changed() { + ui.data_mut(|d| d.insert_temp(key, buf.clone())); + } else { + ui.data_mut(|d| d.remove::(key)); + } + (resp.lost_focus() && buf != text).then_some(buf) +} + +pub fn metadata(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + let Ok(v) = app.session.execute("item.metadata", json!({})) else { return }; + let mut y = rect.min.y + 8.0; + let x0 = rect.min.x + 12.0; + let xv = x0 + 120.0; + let section = |p: &egui::Painter, y: &mut f32, title: &str| { + p.text(pos2(x0, *y + 8.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text); + *y += 22.0; + }; + if let Some(item) = v.get("item").filter(|i| !i.is_null()) { + let name = item["name"].as_str().unwrap_or_default().to_string(); + let id = item["id"].as_u64().unwrap_or(0); + section(&p, &mut y, &format!("{name} ({})", item["type"].as_str().unwrap_or(""))); + kit::label(ui, pos2(x0, y), "Comment", &t); + let r = Rect::from_min_size(pos2(xv, y), vec2((rect.max.x - xv - 12.0).max(80.0), 40.0)); + app.auto.add("metadata.itemComment", r, item["comment"].as_str().unwrap_or("")); + if let Some(c) = comment_field(ui, r, &format!("item{id}"), item["comment"].as_str().unwrap_or("")) { + kit::exec(app, "project.setComment", json!({"items": [id], "comment": c})); + } + y += 48.0; + for (k, val) in rows_of(item) { + if matches!(k.as_str(), "name" | "type") { + continue; + } + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &k, Tokens::ui(11.5), t.text_dim); + p.text(pos2(xv, y + 8.0), Align2::LEFT_CENTER, &val, Tokens::ui(11.5), t.text); + app.auto.add(&format!("metadata.item.{k}"), Rect::from_min_size(pos2(xv, y), vec2(200.0, 16.0)), &val); + y += 18.0; + } + y += 10.0; + } else { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Select an item in the Project panel", Tokens::ui(12.0), t.text_faint); + y += 28.0; + } + let proj = v["project"].clone(); + section(&p, &mut y, "Project"); + kit::label(ui, pos2(x0, y), "Comment", &t); + let r = Rect::from_min_size(pos2(xv, y), vec2((rect.max.x - xv - 12.0).max(80.0), 40.0)); + app.auto.add("metadata.projectComment", r, proj["comment"].as_str().unwrap_or("")); + if let Some(c) = comment_field(ui, r, "project", proj["comment"].as_str().unwrap_or("")) { + kit::exec(app, "project.setProjectComment", json!({"comment": c})); + } + y += 48.0; + for (k, val) in rows_of(&proj) { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &k, Tokens::ui(11.5), t.text_dim); + p.text(pos2(xv, y + 8.0), Align2::LEFT_CENTER, &val, Tokens::ui(11.5), t.text); + y += 18.0; + } +} + +// ---------------------------------------------------------------- Progress + +pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + if !app.session.tasks.is_empty() { + app.session.poll_jobs(); + } + let jobs = app.session.jobs(); + let mut y = rect.min.y + 8.0; + let x0 = rect.min.x + 12.0; + let w = (rect.width() - 24.0).max(60.0); + if jobs.is_empty() { + p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "No background jobs running", Tokens::ui(12.0), t.text_faint); + y += 28.0; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(150)); + } + for j in &jobs { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &j.label, Tokens::medium(12.0), t.text); + let frac = j.fraction.unwrap_or(0.0) as f32; + let pct = j.fraction.map(|f| format!("{:.0} %", f * 100.0)).unwrap_or_else(|| "…".into()); + p.text(pos2(x0 + w - 70.0, y + 8.0), Align2::RIGHT_CENTER, format!("{pct} {}", j.message), Tokens::ui(11.0), t.text_dim); + let bar = Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0)); + p.rect_filled(bar, 3.0, t.field_bg); + p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent); + app.auto.add(&format!("progress.job.{}", j.id), Rect::from_min_size(pos2(x0, y), vec2(w, 28.0)), &j.label); + if j.cancellable + && kit::button(app, ui, Rect::from_min_size(pos2(x0 + w - 64.0, y + 12.0), vec2(64.0, 18.0)), &format!("progress.cancel.{}", j.id), "Cancel", false) + { + kit::exec(app, "jobs.cancel", json!({"job": j.id})); + } + y += 38.0; + } + if !app.session.job_log.is_empty() { + p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator)); + y += 6.0; + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Finished", Tokens::semibold(11.5), t.text_dim); + y += 20.0; + for r in app.session.job_log.iter().rev().take(20) { + let col = match r.status.as_str() { + "done" => t.text, + "cancelled" => t.text_dim, + _ => t.danger, + }; + let msg = if r.message.is_empty() { format!("{} · {:.1} s", r.status, r.seconds) } else { format!("{} · {}", r.status, r.message) }; + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &r.label, Tokens::ui(11.5), col); + p.text(pos2(x0 + w, y + 8.0), Align2::RIGHT_CENTER, msg, Tokens::ui(11.0), t.text_faint); + y += 18.0; + if y > rect.max.y { + break; + } + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2e6d03aeb..31daf811b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -3,6 +3,7 @@ pub mod anim_tools; pub mod camera_tracker_ui; pub mod comp_settings; +pub mod content_fill_panel; pub mod dialogs; pub mod dialogs_3d; pub mod effect_controls; @@ -10,6 +11,7 @@ pub mod effects_presets; pub mod essential; pub mod expr_bar; pub mod flowchart; +pub mod footage_panel; pub mod forms; pub mod fx_widgets; pub mod graph; @@ -20,14 +22,17 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod markers_ui; +pub mod media_panels; pub mod misc; pub mod paint_panels; +pub mod panel_kit; pub mod precomp; pub mod project; pub mod properties; pub mod puppet_tool; pub mod render_queue; pub mod roto_tool; +pub mod scopes_panel; pub mod script_console; pub mod settings; pub mod shortcut_editor; @@ -57,6 +62,8 @@ pub enum DragPayload { Effect(String), /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, + /// Files from the Media Browser (import; into the timeline: import and add). + Files(Vec), } /// The current time formatted per project settings (timecode with `;` for drop-frame, or frames). @@ -103,6 +110,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Brushes => paint_panels::brushes(app, ui, rect), PanelKind::EssentialGraphics => essential::show(app, ui, rect), PanelKind::Flowchart => flowchart::show(app, ui, rect), + PanelKind::LumetriScopes => scopes_panel::show(app, ui, rect), + PanelKind::Footage => footage_panel::show(app, ui, rect), + PanelKind::MediaBrowser => media_panels::media_browser(app, ui, rect), + PanelKind::Metadata => media_panels::metadata(app, ui, rect), + PanelKind::Progress => media_panels::progress(app, ui, rect), + PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect), } } diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs new file mode 100644 index 000000000..cbf28c8ac --- /dev/null +++ b/crates/ui-egui/src/panels/panel_kit.rs @@ -0,0 +1,70 @@ +//! Small immediate-mode controls shared by the Lumetri Scopes, Footage, Media Browser, Metadata, +//! Progress and Content-Aware Fill panels. Each registers its automation id. + +use egui::{Align2, Rect, pos2, vec2}; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// A dropdown; returns the newly chosen index. +pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option { + let t = app.tokens; + let id = egui::Id::new(("kit-dd", auto)); + let text = options.get(cur).copied().unwrap_or(""); + if widgets::dropdown(ui, r, text, &t, id).clicked() { + widgets::open_popup(ui, id); + } + app.auto.add(auto, r, text); + let opts: Vec = options.iter().map(|s| s.to_string()).collect(); + widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur) +} + +/// A checkbox with a label; returns the new state. +pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { + let t = app.tokens; + let r = Rect::from_min_size(at, vec2(16.0, 16.0)); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked(); + ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); + let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0)); + app.auto.add(auto, full, text); + if clicked { !on } else { on } +} + +/// A text button; true when clicked. +pub fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, text: &str, primary: bool) -> bool { + let t = app.tokens; + let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::new(("kit-btn", auto))).clicked(); + app.auto.add(auto, r, text); + clicked +} + +/// A dimmed label at (x, y-row). +pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) { + ui.painter().text(pos2(at.x, at.y + 9.0), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text_dim); +} + +/// A draggable number; returns the new value. +#[allow(clippy::too_many_arguments)] +pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { + let t = app.tokens; + let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("kit-n", auto)), v, speed, range, dec, suffix, &t); + app.auto.add(auto, r, &format!("{v}")); + nv.unwrap_or(v) +} + +/// Run a command, reporting errors in the status bar. +pub fn exec(app: &mut EffectcraftApp, id: &str, p: serde_json::Value) -> Option { + match app.session.execute(id, p) { + Ok(v) => Some(v), + Err(e) => { + app.ui.status = e.to_string(); + None + } + } +} + +/// Format seconds as `m:ss.ff`. +pub fn seconds(s: f64) -> String { + let m = (s / 60.0).floor(); + format!("{}:{:05.2}", m as i64, s - m * 60.0) +} diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index ce750fac2..f3898954d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -481,6 +481,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { app.ui.project_open_folders.insert(id.0); } + // Footage (not data) and solids open in the Footage panel. + ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => { + actions.push(("footage.open".into(), json!({"item": id.0}))) + } + ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))), _ => {} } } @@ -539,6 +544,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::DragAndDrop::clear_payload(&ctx); } } + // Files dragged from the Media Browser: import. + if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && hover_y.is_some() + { + lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(("mediaBrowser.import".into(), json!({"paths": paths}))); + egui::DragAndDrop::clear_payload(&ctx); + } + } // Drag ghost. if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() diff --git a/crates/ui-egui/src/panels/scopes_panel.rs b/crates/ui-egui/src/panels/scopes_panel.rs new file mode 100644 index 000000000..d220213be --- /dev/null +++ b/crates/ui-egui/src/panels/scopes_panel.rs @@ -0,0 +1,197 @@ +//! Lumetri Scopes panel (Window ▸ Lumetri Scopes): waveform (RGB / Luma / YC), vectorscope +//! (YUV / HLS), histogram and parade (RGB / YUV) of the active composition's frame at the current +//! time, with the colour standard (Rec. 601 / 709 / 2020), 8-bit or float scale and clamp. The +//! maths is `effectcraft_raster::scopes`; agents read the same numbers with `scopes.analyze`. + +use effectcraft_engine::commands::panels_cmds::scope_frame; +use effectcraft_engine::raster::scopes::{self, ColorStandard, Scope, ScopeKind, ScopeOpts}; +use egui::{Align2, Color32, Rect, Stroke, pos2, vec2}; +use serde::{Deserialize, Serialize}; + +use super::panel_kit as kit; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +/// Panel options (serde in the UI state). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ScopesState { + pub scope: String, + pub standard: String, + pub float: bool, + pub clamp: bool, +} + +impl Default for ScopesState { + fn default() -> Self { + ScopesState { scope: "waveformRgb".into(), standard: "rec709".into(), float: false, clamp: true } + } +} + +const STANDARDS: [&str; 3] = ["Rec. 601", "Rec. 709", "Rec. 2020"]; + +#[derive(Clone)] +struct Cached { + key: (u64, u64, i64, String, u32), + scope: Scope, + tex: Option, +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.panel_bg); + let mut st = app.ui.scopes.clone(); + let kind = ScopeKind::from_name(&st.scope).unwrap_or(ScopeKind::WaveformRgb); + let standard = ColorStandard::from_name(&st.standard).unwrap_or_default(); + // Top bar: scope, standard, 8-bit / float, clamp. + let mut x = rect.min.x + 8.0; + let y = rect.min.y + 6.0; + let labels: Vec<&str> = ScopeKind::ALL.iter().map(|k| k.label()).collect(); + let cur = ScopeKind::ALL.iter().position(|k| *k == kind).unwrap_or(0); + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(150.0, 20.0)), "scopes.kind", &labels, cur) { + st.scope = ScopeKind::ALL[i].name().into(); + } + x += 158.0; + let si = ColorStandard::ALL.iter().position(|c| *c == standard).unwrap_or(1); + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(90.0, 20.0)), "scopes.standard", &STANDARDS, si) { + st.standard = ColorStandard::ALL[i].name().into(); + } + x += 98.0; + if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(70.0, 20.0)), "scopes.scale", &["8 Bit", "Float"], usize::from(st.float)) { + st.float = i == 1; + } + x += 78.0; + st.clamp = kit::checkbox(app, ui, pos2(x, y + 2.0), "scopes.clamp", "Clamp Signal", st.clamp); + if st != app.ui.scopes { + app.ui.scopes = st.clone(); + } + let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 34.0), pos2(rect.max.x - 8.0, rect.max.y - 8.0)); + if area.width() < 40.0 || area.height() < 40.0 { + return; + } + p.rect_filled(area, 2.0, Color32::from_rgb(0x10, 0x10, 0x10)); + app.auto.add("scopes.plot", area, kind.label()); + let Some(cid) = app.session.active_comp_id() else { + p.text(area.center(), Align2::CENTER_CENTER, "Open a composition to see its scopes", Tokens::ui(12.0), t.text_faint); + return; + }; + // Plot rectangle: square for vectorscopes. + let plot = if kind.is_vectorscope() { + let s = area.width().min(area.height()) - 16.0; + Rect::from_center_size(area.center(), vec2(s, s)) + } else { + Rect::from_min_max(area.min + vec2(34.0, 8.0), area.max - vec2(8.0, 8.0)) + }; + let size = (plot.width().max(plot.height()) as u32).clamp(64, 512); + let o = ScopeOpts { + standard, + float: st.float, + clamp: st.clamp, + width: if kind.is_vectorscope() { size } else { (plot.width() as u32).clamp(64, 768) }, + height: if kind.is_vectorscope() { size } else { (plot.height() as u32).clamp(32, 512) }, + }; + let time = app.session.time(); + let key = (app.session.revision, cid.0, time.0, format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height), 0); + let ctx = ui.ctx().clone(); + let id = egui::Id::new("scopes-cache"); + let cached: Option = ctx.data(|d| d.get_temp(id)); + let cached = match cached { + Some(c) if c.key == key => c, + _ => { + let Some(frame) = scope_frame(&app.session, cid, time, 480) else { return }; + let scope = scopes::compute(&frame, kind, &o); + let tex = (kind != ScopeKind::Histogram).then(|| { + let mono = matches!(kind, ScopeKind::WaveformLuma | ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls); + let img = egui::ColorImage::from_rgba_unmultiplied([scope.width as usize, scope.height as usize], &tint(&scope, mono)); + ctx.load_texture("lumetri-scope", img, egui::TextureOptions::LINEAR) + }); + let c = Cached { key, scope, tex }; + ctx.data_mut(|d| d.insert_temp(id, c.clone())); + c + } + }; + graticule(&p, plot, kind, &o, &t); + if let Some(tex) = &cached.tex { + p.image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } else { + histogram(&p, plot, &cached.scope); + } +} + +/// Trace colours: green-tinted mono for luma/vectorscope, per-channel colours otherwise. +fn tint(s: &Scope, mono: bool) -> Vec { + let n = s.width as usize * s.height as usize; + let mut out = vec![0u8; n * 4]; + for i in 0..n { + let (r, g, b) = (s.planes[0][i], s.planes[1][i], s.planes[2][i]); + let (r, g, b) = if mono { + let m = r.max(g).max(b); + (m * 0.75, m, m * 0.75) + } else { + (r, g, b) + }; + let a = r.max(g).max(b).min(1.0); + out[i * 4] = (r.min(1.0) * 255.0) as u8; + out[i * 4 + 1] = (g.min(1.0) * 255.0) as u8; + out[i * 4 + 2] = (b.min(1.0) * 255.0) as u8; + out[i * 4 + 3] = (a * 255.0) as u8; + } + out +} + +fn graticule(p: &egui::Painter, plot: Rect, kind: ScopeKind, o: &ScopeOpts, t: &Tokens) { + let line = Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 28)); + if kind.is_vectorscope() { + let c = plot.center(); + for r in [0.25f32, 0.5, 0.75, 1.0] { + p.circle_stroke(c, plot.width() / 2.0 * r, line); + } + p.line_segment([pos2(plot.min.x, c.y), pos2(plot.max.x, c.y)], line); + p.line_segment([pos2(c.x, plot.min.y), pos2(c.x, plot.max.y)], line); + for (name, [u, v]) in scopes::vector_targets(o.standard, kind == ScopeKind::VectorscopeHls) { + let q = pos2(plot.min.x + (u + 0.5) * plot.width(), plot.min.y + (0.5 - v) * plot.height()); + p.rect_stroke(Rect::from_center_size(q, vec2(8.0, 8.0)), 0.0, Stroke::new(1.0, Color32::from_rgb(0xc8, 0x6a, 0x6a)), egui::StrokeKind::Middle); + p.text(q + vec2(7.0, -7.0), Align2::LEFT_BOTTOM, name, Tokens::ui(10.0), t.text_dim); + } + return; + } + if kind == ScopeKind::Histogram { + return; + } + let (lo, hi) = o.range(); + let marks: &[f32] = if o.float { &[-0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 1.25] } else { &[0.0, 0.25, 0.5, 0.75, 1.0] }; + for v in marks { + let y = plot.max.y - (v - lo) / (hi - lo) * plot.height(); + p.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], line); + let label = if o.float { format!("{v:.2}") } else { format!("{}", (v * 255.0).round() as i32) }; + p.text(pos2(plot.min.x - 4.0, y), Align2::RIGHT_CENTER, label, Tokens::ui(9.5), t.text_faint); + } + if matches!(kind, ScopeKind::ParadeRgb | ScopeKind::ParadeYuv) { + for k in 1..3 { + let x = plot.min.x + plot.width() * k as f32 / 3.0; + p.line_segment([pos2(x, plot.min.y), pos2(x, plot.max.y)], line); + } + } +} + +fn histogram(p: &egui::Painter, plot: Rect, s: &Scope) { + let bars = scopes::histogram_bars(s); + let max = bars.iter().flat_map(|b| b.iter()).copied().max().unwrap_or(1).max(1) as f32; + let cols = [ + Color32::from_rgba_unmultiplied(230, 70, 70, 150), + Color32::from_rgba_unmultiplied(70, 220, 90, 150), + Color32::from_rgba_unmultiplied(80, 120, 255, 150), + ]; + let n = bars[0].len().max(1) as f32; + for (c, b) in bars.iter().enumerate() { + let pts: Vec = + b.iter().enumerate().map(|(i, v)| pos2(plot.min.x + i as f32 / n * plot.width(), plot.max.y - (*v as f32 / max).sqrt() * plot.height())).collect(); + for (i, q) in pts.iter().enumerate() { + p.line_segment([pos2(q.x, plot.max.y), *q], Stroke::new((plot.width() / n).max(1.0), cols[c].gamma_multiply(0.5))); + if i > 0 { + p.line_segment([pts[i - 1], *q], Stroke::new(1.0, cols[c])); + } + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 10ad62e6a..72f39df8a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1986,6 +1986,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); } } + crate::panels::DragPayload::Files(paths) => actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true}))), crate::panels::DragPayload::Property { .. } => {} } egui::DragAndDrop::clear_payload(&ctx); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index adffbbeb7..07715d169 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -612,6 +612,9 @@ pub struct UiState { /// Flowchart panel options (layers, effects, solids, direction, root comp). #[serde(default)] pub flowchart: crate::panels::flowchart::FlowOptions, + /// Lumetri Scopes panel options. + #[serde(default)] + pub scopes: crate::panels::scopes_panel::ScopesState, } impl Default for UiState { @@ -665,6 +668,7 @@ impl Default for UiState { anim_tools: AnimToolsState::default(), mini_flowchart: None, flowchart: Default::default(), + scopes: Default::default(), } } } diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs new file mode 100644 index 000000000..67d3db92e --- /dev/null +++ b/crates/ui-egui/tests/ui_panels.rs @@ -0,0 +1,104 @@ +//! Headless checks for the M13.6 panels: each opens from the Window menu as a real panel and +//! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress +//! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Panels", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, panel: &str) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel": panel})).unwrap(); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn window_menu_opens_the_new_panels() { + let mut h = harness(); + for (name, kind, auto) in [ + ("lumetriScopes", PanelKind::LumetriScopes, "scopes.kind"), + ("footage", PanelKind::Footage, "footage.empty"), + ("mediaBrowser", PanelKind::MediaBrowser, "mediaBrowser.path"), + ("metadata", PanelKind::Metadata, "metadata.projectComment"), + ("progress", PanelKind::Progress, ""), + ("contentAwareFill", PanelKind::ContentAwareFill, "contentFill.method"), + ] { + open(&mut h, name); + assert!(h.state().ui.dock.contains(kind) || h.state().ui.floating.iter().any(|f| f.panels.contains(&kind)), "{name} shown"); + if !auto.is_empty() { + assert!(h.state().auto.find(auto).is_some(), "{name}: {auto}"); + } + } +} + +#[test] +fn scopes_switch_kind() { + let mut h = harness(); + open(&mut h, "lumetriScopes"); + assert!(h.state().auto.find("scopes.plot").is_some()); + h.state_mut().ui.scopes.scope = "vectorscopeYuv".into(); + h.run_steps(2); + assert_eq!(h.state().auto.find("scopes.plot").unwrap().label, "Vectorscope YUV"); + h.state_mut().ui.scopes.scope = "histogram".into(); + h.run_steps(2); + assert_eq!(h.state().auto.find("scopes.kind").unwrap().label, "Histogram"); +} + +#[test] +fn footage_panel_buttons_edit_into_the_comp() { + let mut h = harness(); + let item = h.state().session.project.items.values().find(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).unwrap().id.0; + h.state_mut().session.execute("footage.open", json!({"item": item})).unwrap(); + h.run_steps(4); + assert!(h.state().ui.dock.contains(PanelKind::Footage) || h.state().ui.floating.iter().any(|f| f.panels.contains(&PanelKind::Footage))); + assert!(h.state().auto.find("footage.overlayEdit").is_some()); + let before = h.state().session.active_comp().unwrap().layers.len(); + click(&mut h, "footage.overlayEdit"); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), before + 1); + click(&mut h, "footage.rippleInsertEdit"); + assert!(h.state().session.active_comp().unwrap().layers.len() >= before + 2); +} + +#[test] +fn progress_panel_cancels_a_job() { + let mut h = harness(); + h.state_mut() + .session + .spawn_task("test", "Long analysis", false, |ctl| { + while ctl.progress(1, 2) { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + Err("cancelled".into()) + }) + .unwrap(); + open(&mut h, "progress"); + let id = h.state().session.jobs()[0].id.clone(); + assert!(h.state().auto.find(&format!("progress.job.{id}")).is_some()); + click(&mut h, &format!("progress.cancel.{id}")); + h.state_mut().session.wait_jobs(); + h.run_steps(2); + assert!(h.state().session.jobs().is_empty()); + assert_eq!(h.state().session.job_log.last().unwrap().status, "cancelled"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 39acd3962..f37f4e731 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,6 +267,42 @@ demand (`self_at`) and `roto.propagate` fills in the background; `Session::edit` Key in step with the layer's source, masks and upstream effects. Freeze stores final 8-bit mattes in the effect. +**Content-Aware Fill** (`raster::inpaint`, `engine::commands::content_fill`): the hole is +where the layer's masked source (`Renderer::layer_input` with no effects) is under half +opacity, grown by Alpha Expansion. Optical flow between neighbouring membrane-filled frames is +*completed* inside the holes (membrane interpolation of the surrounding tiles' motion); each +missing pixel follows it forwards and backwards to the nearest frame (or reference frame) where +it is visible, the two candidates blended by temporal distance, with Lighting Correction adding +the membrane of the border mismatch (Poisson-style). What no frame shows is synthesised by +PatchMatch (Barnes et al. 2009) inside Wexler et al.'s (2007) multi-scale EM: Object refines each +frame starting from the previous result warped along the flow; Surface carries one synthesised +fill through the range; Edge Blend is the membrane fill alone. The frames are written as a PNG +sequence and imported into a "Fill" layer above the source. It runs as a generic **background +task** (`engine::jobs`: a closure with progress and cancel on a thread, inline on wasm32 or +with `wait`, whose result is applied on the UI thread as one undo step); the Progress panel and +`jobs.list` / `jobs.cancel` show these tasks together with the render queue and the analyses. + +**Scene Edit Detection** (`raster::cuts`) compares consecutive frames by colour-histogram +distance and motion-compensated (block-flow warped) luma error, takes their geometric mean and +marks frames that exceed a threshold and several times their neighbours' median; the cuts +become layer markers, split layers, or split-and-precomposed layers. **Auto-trace** +(`path::trace`) runs marching squares on the chosen channel, Douglas–Peucker simplification and +cubic fitting with Corner Roundness blending chord and smooth tangents; holes become Subtract +masks, and a work-area trace keys Mask Path per frame. + +**Align Video to Data** reads data footage (File ▸ Import of `.json`, `.csv`, `.tsv`, kept as +text in the project). Supported data: JSON as an array of objects or an object holding one +(`{"samples": [...]}`), CSV/TSV with a header row (quoted fields allowed). The time key is the +`key` parameter or the first field named `time`, `timestamp`, `t`, `date`, `datetime`, `utc`, +`gps_time`, `seconds` or `time_s`; values may be ISO 8601 date-times (with `Z` or `±hh:mm`), +times of day (`hh:mm:ss.sss`), timecode (`hh:mm:ss:ff` at the footage rate), Unix seconds or +Unix milliseconds. The layer's Start Time becomes `dataStart + videoStart − firstSample` +(`videoStart` given, or the file's creation date on the desktop; reduced to a time of day when +the data has no dates). + +**Lumetri Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the +comp background) with BT.601 / BT.709 / BT.2020 luma and colour-difference coefficients. + ## 5. Expressions Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`, diff --git a/docs/parity.md b/docs/parity.md index fbe12a5fe..a38ed8400 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -24,9 +24,8 @@ renders); stroke taper/wave and multi-segment dashes; variable mask feather poin rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur and blend modes; a few viewer and colour-management menu items; OpenType features and variable -font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; -Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and -the "better than After Effects" items (a plug-in API, branching history, GPU particles). +font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; face +tracking; and the "better than After Effects" items (a plug-in API, branching history, GPU particles). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -167,6 +166,25 @@ style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances. +## Update: M13.6 panels and content tools + +The last placeholder panels became real panels with automation ids: **Lumetri Scopes** +(waveform RGB / Luma / YC, vectorscope YUV / HLS, histogram, parade RGB / YUV; Rec. 601 / 709 / +2020, 8-bit or float scale, clamp; `scopes.analyze`), **Footage** (double-click footage: its own +time ruler, play, In/Out, Overlay Edit and Ripple Insert Edit; `footage.*`), **Media Browser** +(desktop file system, favourites, thumbnails, import and drag to the Project panel or timeline; +`mediaBrowser.*`), **Metadata** (codec, size, rate, duration, colour profile, file dates; item +and project comments; `item.metadata`) and **Progress** (every render and analysis with +progress and cancel; `jobs.*`). **Content-Aware Fill** (panel and Layer ▸ New ▸ Content-Aware +Fill Layer): Object / Surface / Edge Blend, Work Area / Entire Duration, Alpha Expansion, +Lighting Correction and a reference frame from a layer, rendered to a PNG sequence in a Fill +layer above the source as a background job. **Scene Edit Detection** (markers, split, split and +precompose), **Auto-trace** (alpha / RGB / luminance, current frame or work area, to the layer +or a new layer) and **Align Video to Data** (JSON / CSV / TSV time keys) replace their disabled +menu entries. Not yet: creating a reference frame by editing a still (After Effects hands it to +Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media +Browser in the web app. + ## Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). From e362cae4c75d8d0145f0f36e03ceebdfb97f6220 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:21:30 -0400 Subject: [PATCH 13/20] M13.6: panel polish (log-density scopes, narrow-panel layouts) and an offscreen panels snapshot test Co-Authored-By: Claude Opus 5.5 --- crates/raster/src/scopes.rs | 9 +++-- .../ui-egui/src/panels/content_fill_panel.rs | 2 +- crates/ui-egui/src/panels/media_panels.rs | 10 +++-- crates/ui-egui/src/panels/scopes_panel.rs | 6 ++- crates/ui-egui/tests/ui_panels.rs | 40 +++++++++++++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/raster/src/scopes.rs b/crates/raster/src/scopes.rs index befb26ef3..6b803a12e 100644 --- a/crates/raster/src/scopes.rs +++ b/crates/raster/src/scopes.rs @@ -5,7 +5,8 @@ //! ITU-R BT.601 / BT.709 / BT.2020 coefficients, colour differences `Cb = (B − Y) / (2(1 − Kb))` //! and `Cr = (R − Y) / (2(1 − Kr))` (each in −0.5…0.5). Values are read straight (un-premultiplied) //! from the frame. Every scope is a [`Scope`]: a `width × height` grid of three density planes -//! (one per trace colour) normalised so the busiest cell is 1, ready to be tinted and drawn. +//! (one per trace colour) on a logarithmic scale where the busiest cell is 1, ready to be tinted +//! and drawn. //! //! Scales: **8-bit** maps 0…1 to the plot (0…255 graticule); **float** maps −0.25…1.25 so //! super-white and sub-black values stay visible. **Clamp** limits the signal to 0…1 first. @@ -178,10 +179,12 @@ impl Scope { self } fn normalise(&mut self) { - // Square-root density so faint traces stay visible next to dense ones. + // Logarithmic density (like a phosphor trace) so faint traces stay visible next to + // dense ones. let max = self.counts.iter().flat_map(|p| p.iter()).copied().max().unwrap_or(0).max(1) as f32; + let k = 1.0 / (1.0 + max).ln(); for c in 0..3 { - self.planes[c] = self.counts[c].iter().map(|&n| (n as f32 / max).sqrt()).collect(); + self.planes[c] = self.counts[c].iter().map(|&n| if n == 0 { 0.0 } else { (0.25 + 0.75 * (1.0 + n as f32).ln() * k).min(1.0) }).collect(); } } /// Total count of plane `c`. diff --git a/crates/ui-egui/src/panels/content_fill_panel.rs b/crates/ui-egui/src/panels/content_fill_panel.rs index 5d534e595..1937b4cc9 100644 --- a/crates/ui-egui/src/panels/content_fill_panel.rs +++ b/crates/ui-egui/src/panels/content_fill_panel.rs @@ -45,7 +45,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .unwrap_or(0); format!("{} — {masks} subtracting mask(s)", l.name) } - None => "Select a layer with transparent areas (e.g. a Subtract mask)".into(), + None => "Select a layer with a Subtract mask".into(), }; p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, &target, Tokens::ui(11.5), if layer.is_some() { t.text_dim } else { t.text_faint }); app.auto.add("contentFill.target", Rect::from_min_size(pos2(x0, y), vec2(rect.width() - 24.0, 18.0)), &target); diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index b1a3230eb..4f6fd0ae3 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -104,11 +104,13 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if only != st.importable_only { kit::exec(app, "mediaBrowser.go", json!({"path": dir, "importableOnly": only})); } - x += 100.0; - p.text(pos2(x, y + 10.0), Align2::LEFT_CENTER, &dir, Tokens::ui(11.5), t.text_dim); - app.auto.add("mediaBrowser.path", Rect::from_min_max(pos2(x, y), pos2(rect.max.x - 8.0, y + 20.0)), &dir); + // The path on its own row. + let _ = x; + let py = y + 24.0; + p.text(pos2(rect.min.x + 10.0, py + 9.0), Align2::LEFT_CENTER, &dir, Tokens::ui(11.5), t.text_dim); + app.auto.add("mediaBrowser.path", Rect::from_min_max(pos2(rect.min.x + 8.0, py), pos2(rect.max.x - 8.0, py + 18.0)), &dir); // Left column: favourites. - let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 32.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y)); + let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y)); p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator)); p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, "Favorites", Tokens::semibold(11.5), t.text_dim); let mut fy = side.min.y + 22.0; diff --git a/crates/ui-egui/src/panels/scopes_panel.rs b/crates/ui-egui/src/panels/scopes_panel.rs index d220213be..db5279d8b 100644 --- a/crates/ui-egui/src/panels/scopes_panel.rs +++ b/crates/ui-egui/src/panels/scopes_panel.rs @@ -62,11 +62,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { st.float = i == 1; } x += 78.0; - st.clamp = kit::checkbox(app, ui, pos2(x, y + 2.0), "scopes.clamp", "Clamp Signal", st.clamp); + // Narrow panels: Clamp Signal on a second row. + let (cx, cy, top) = if x + 110.0 > rect.max.x { (rect.min.x + 8.0, y + 26.0, 60.0) } else { (x, y, 34.0) }; + st.clamp = kit::checkbox(app, ui, pos2(cx, cy + 2.0), "scopes.clamp", "Clamp Signal", st.clamp); if st != app.ui.scopes { app.ui.scopes = st.clone(); } - let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 34.0), pos2(rect.max.x - 8.0, rect.max.y - 8.0)); + let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + top), pos2(rect.max.x - 8.0, rect.max.y - 8.0)); if area.width() < 40.0 || area.height() < 40.0 { return; } diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 67d3db92e..ee027daab 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -102,3 +102,43 @@ fn progress_panel_cancels_a_job() { assert!(h.state().session.jobs().is_empty()); assert_eq!(h.state().session.job_log.last().unwrap().status, "cancelled"); } + +/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with +/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`. +#[test] +#[ignore] +fn panels_snapshot() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let solid = s.project.items.values().find(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0); + let mut app = Some(EffectcraftApp::new(s)); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app")); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "All Panels"})).unwrap(); + h.run_steps(3); + let dir = std::env::var("PANELS_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out").into()); + std::fs::create_dir_all(&dir).unwrap(); + if let Some(id) = solid { + h.state_mut().session.execute("footage.open", json!({"item": id})).unwrap(); + } + for (panel, scope) in [ + ("lumetriScopes", "waveformRgb"), + ("lumetriScopes", "vectorscopeYuv"), + ("lumetriScopes", "histogram"), + ("lumetriScopes", "paradeRgb"), + ("contentAwareFill", ""), + ("metadata", ""), + ("progress", ""), + ("mediaBrowser", ""), + ("footage", ""), + ] { + if !scope.is_empty() { + h.state_mut().ui.scopes.scope = scope.into(); + } + open(&mut h, panel); + h.run_steps(3); + let img = h.render().expect("render"); + img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); + } +} From 02c9a3f13c255c22249f3fa8c4d43578734bd1be Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:38:33 -0400 Subject: [PATCH 14/20] M13.2: collapsed precomps project their 3D layers (and environment sky) with the parent comp's view size Co-Authored-By: Claude Opus 5.5 --- crates/render/src/tests_collapse.rs | 34 ++++++++++++++++++++++++++++ crates/render/src/three_d/compose.rs | 11 +++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index d3cf954cf..0da0288f6 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -200,3 +200,37 @@ fn wireframe_quality_draws_the_layer_bounds() { let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); } + +#[test] +fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { + // A 1000×600 scene comp with a blue 3D solid at its centre, collapsed into a 300×200 comp + // whose camera looks at the precomp layer (at the parent's centre). The nested layers must + // be projected with the parent comp's view (its centre and size), not the scene comp's. + let run = |three_d_precomp: bool, camera: bool| { + let mut p = project(); + let outer = Comp::new(300, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(1000, 600, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); + blue.switches.three_d = true; + let mut inner = inner; + inner.layers = vec![blue]; + let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); + pre.switches.three_d = three_d_precomp; + pre.switches.collapse = true; + let mut c = outer.clone(); + c.layers = vec![pre]; + if camera { + let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); + c.layers.insert(0, cam); + } + let cid = p.add_item("Face", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + for (three_d, camera) in [(true, true), (true, false), (false, true)] { + let img = run(three_d, camera); + let c = img.get(150, 100); + assert!(c[2] > 0.99 && c[3] > 0.99, "3D precomp {three_d}, camera {camera}: centre {c:?}"); + assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn"); + } +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index b134c88f2..c97b78bb4 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -161,6 +161,13 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { } } +/// The size of the comp the camera looks through: a collapsed precomp's layers are projected +/// into the outermost comp they collapse into (its centre and size), not their own comp's. +pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) { + let c = r.collapse3d.map_or(ctx.comp, |c| c.parent.comp); + (c.width as f64, c.height as f64) +} + /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { if r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { @@ -202,7 +209,7 @@ fn prepare_with<'a>( if buf.img.is_empty() { return None; } - let comp = (ctx.comp.width as f64, ctx.comp.height as f64); + let comp = view_size(r, ctx); let s = r.opts.scale; let cam = camera_for(r, ctx); let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); @@ -920,7 +927,7 @@ pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut let cam = camera_for(r, ctx); let (fwd, right, down) = (cam.forward(), cam.right(), cam.down()); let s = r.opts.scale.max(1e-9); - let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64); + let (cw, ch) = view_size(r, ctx); let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)); let rot = environment_rotation(ctx); let (iw, ih) = (img.width as f64, img.height as f64); From 4210f003023c265fed531c0004eea8ad16183633 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:40:59 -0400 Subject: [PATCH 15/20] M14.5: fmt after merge Co-Authored-By: Claude Opus 5.5 --- crates/render/src/three_d/compose.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index c2dd4ec07..a4bf7f20d 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -682,8 +682,7 @@ pub struct Plane3d { /// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU /// (adjustment, wireframe or environment background layers, Advanced 3D). pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option { - if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) - { + if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) { return None; } if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { From 30a92ba82f5e2d064e3d4265957fe7274cef95b5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:53:05 -0400 Subject: [PATCH 16/20] M13.2: OpenType features (stylistic sets, figures, fractions, ordinals, dlig, true small caps / superscripts with faux fallback), Character panel OpenType popup, text.fontFeatures Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 2 +- crates/engine/src/commands/text_edit.rs | 41 ++++ crates/engine/src/lib.rs | 5 + crates/engine/src/tests_text_edit.rs | 25 +++ crates/keyframe/src/lib.rs | 2 +- crates/keyframe/src/text_doc.rs | 243 +++++++++++++++++++++++ crates/text/src/fonts.rs | 15 ++ crates/text/src/layout.rs | 207 +++++++++++++++++-- crates/text/src/lib.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 108 +++++++++- 10 files changed, 628 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 29b4c595d..bf87a96db 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1157,7 +1157,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 280483890..37ed9da04 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -465,6 +465,38 @@ pub(crate) fn sanitize(s: &mut Session) { } } +/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the +/// caret or its first character), and which Character panel options it sets with its own glyphs +/// (the others are synthesized or have no effect). +pub fn font_features(s: &mut Session, p: &Value) -> Result { + let (family, style) = match p.get("font").and_then(Value::as_str) { + Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()), + None => { + let lid = target(s, p, "text.fontFeatures")?; + let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?; + let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start); + let st = doc.style_at(at); + (st.font, st.style) + } + }; + let r = effectcraft_text::resolve(&family, &style); + let face = effectcraft_text::fonts::face(r.face); + let feats = face.features(); + let has = |t: &str| feats.iter().any(|f| f == t); + let sets: Vec = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect(); + Ok(json!({ + "family": face.info.family, "style": face.info.style, "missing": r.missing, + "features": feats, + "options": { + "kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"), + "contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets, + "swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"), + "smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"), + "oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"), + }, + })) +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } @@ -485,6 +517,15 @@ pub fn specs() -> Vec { edit ), cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit), + cmd!( + "text.fontFeatures", + "OpenType Features", + [], + None, + "{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)", + always_ok, + font_features + ), cmd!( "text.setSelection", "Set Text Selection", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9333329a1..f28c07a7a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -747,6 +747,11 @@ pub fn text_presets() -> Vec<(String, String)> { pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } + +/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. +pub fn font_features(family: &str, style: &str) -> Vec { + effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() +} #[cfg(test)] mod tests_paint; #[cfg(test)] diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 2476727f0..466107868 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -202,3 +202,28 @@ fn edit_state_round_trips_through_serde() { let back: crate::EditorState = serde_json::from_value(j).unwrap(); assert_eq!(back.text_edit, s.state.text_edit); } + +#[test] +fn opentype_features_per_range_render_and_query() { + let (mut s, t) = session("Office 1/2"); + s.execute("layer.setText", json!({"layer": t, "range": [7, 10], "fractions": true, "ss01": true})).unwrap(); + let d = doc(&s, t); + assert!(!d.style_at(0).opentype.fractions); + assert!(d.style_at(8).opentype.fractions && d.style_at(8).opentype.stylistic_set(1)); + // The laid-out glyphs change (frac turns 1/2 into a fraction). + let plain = effectcraft_text::layout_doc(&TextDoc { text: "1/2".into(), ..Default::default() }); + let frac = effectcraft_text::layout_doc(&d.slice(7..10)); + let ids = |l: &effectcraft_text::TextLayout| l.glyphs.iter().map(|g| g.gid).collect::>(); + assert_ne!(ids(&plain), ids(&frac)); + // Undo restores the uniform style. + s.execute("edit.undo", json!({})).unwrap(); + assert!(doc(&s, t).is_uniform()); + // Font feature query (font by name, or the layer's). + let f = s.execute("text.fontFeatures", json!({"font": "Noto Serif"})).unwrap(); + assert_eq!(f["options"]["smallCaps"], true); + assert!(f["features"].as_array().unwrap().iter().any(|t| t == "onum")); + let f = s.execute("text.fontFeatures", json!({"layer": t})).unwrap(); + assert_eq!(f["family"], "Inter"); + assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1))); + assert_eq!(f["options"]["smallCaps"], false); +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index ec2b47548..8932530d2 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -13,7 +13,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 2e9d5c6ed..ae0b2e568 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -143,6 +143,125 @@ fn yes() -> bool { true } +/// Figure style (OpenType `lnum` / `onum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureStyle { + /// The font's default figures. + #[default] + Default, + Lining, + OldStyle, +} + +/// Figure spacing (OpenType `pnum` / `tnum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureWidth { + #[default] + Default, + Proportional, + Tabular, +} + +/// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning, +/// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`] +/// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize +/// them otherwise). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct OpenType { + /// `dlig`. + pub discretionary_ligatures: bool, + /// `calt` (on by default, as in the font). + pub contextual_alternates: bool, + /// `salt`. + pub stylistic_alternates: bool, + /// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20). + pub stylistic_sets: u32, + /// `swsh`. + pub swash: bool, + /// `titl`. + pub titling: bool, + /// `ordn`. + pub ordinals: bool, + /// `frac`. + pub fractions: bool, + /// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`). + pub all_small_caps: bool, + pub figure_style: FigureStyle, + pub figure_width: FigureWidth, +} + +impl Default for OpenType { + fn default() -> Self { + OpenType { + discretionary_ligatures: false, + contextual_alternates: true, + stylistic_alternates: false, + stylistic_sets: 0, + swash: false, + titling: false, + ordinals: false, + fractions: false, + all_small_caps: false, + figure_style: FigureStyle::Default, + figure_width: FigureWidth::Default, + } + } +} + +impl OpenType { + pub fn is_default(&self) -> bool { + *self == OpenType::default() + } + /// Whether stylistic set `n` (1…20) is on. + pub fn stylistic_set(&self, n: u32) -> bool { + (1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0 + } + pub fn set_stylistic_set(&mut self, n: u32, on: bool) { + if (1..=20).contains(&n) { + if on { + self.stylistic_sets |= 1 << (n - 1); + } else { + self.stylistic_sets &= !(1 << (n - 1)); + } + } + } + /// The OpenType feature settings these options ask for (tag, value), beyond the font's + /// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`. + pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> { + let mut v = Vec::new(); + for (tag, f) in [ + (b"dlig", self.discretionary_ligatures), + (b"salt", self.stylistic_alternates), + (b"swsh", self.swash), + (b"titl", self.titling), + (b"ordn", self.ordinals), + (b"frac", self.fractions), + (b"c2sc", self.all_small_caps), + (b"smcp", self.all_small_caps), + (b"lnum", self.figure_style == FigureStyle::Lining), + (b"onum", self.figure_style == FigureStyle::OldStyle), + (b"pnum", self.figure_width == FigureWidth::Proportional), + (b"tnum", self.figure_width == FigureWidth::Tabular), + ] { + if f { + v.push((*tag, 1)); + } + } + if !self.contextual_alternates { + v.push((*b"calt", 0)); + } + for n in 1..=20u32 { + if self.stylistic_set(n) { + let d = format!("ss{n:02}"); + let b = d.as_bytes(); + v.push(([b[0], b[1], b[2], b[3]], 1)); + } + } + v + } +} + /// The formatting of a run of characters (Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -177,6 +296,9 @@ pub struct CharStyle { /// Vertical type: keep Roman (half-width) characters upright instead of turning them on /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). pub vertical_roman_upright: bool, + /// OpenType features (stylistic sets, figures, fractions…). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, } impl Default for CharStyle { @@ -258,6 +380,9 @@ pub struct TextDoc { /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). pub tate_chu_yoko: bool, pub vertical_roman_upright: bool, + /// Base OpenType features (see [`CharStyle::opentype`]). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -309,6 +434,7 @@ impl Default for TextDoc { ligatures: true, tate_chu_yoko: false, vertical_roman_upright: false, + opentype: OpenType::default(), indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -395,6 +521,7 @@ impl TextDoc { ligatures: self.ligatures, tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, + opentype: self.opentype, } } @@ -422,6 +549,7 @@ impl TextDoc { self.ligatures = s.ligatures; self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; + self.opentype = s.opentype; } /// The base (first paragraph's) settings. @@ -777,6 +905,9 @@ fn color_json(v: &J) -> Option<[f32; 4]> { fn num(key: &str, v: &J) -> Result { v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) } +fn norm(v: &J) -> Option { + v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")) +} fn flag(key: &str, v: &J) -> Result { v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) } @@ -808,6 +939,18 @@ pub const CHAR_ATTRS: &[&str] = &[ "ligatures", "tateChuYoko", "verticalRomanUpright", + "discretionaryLigatures", + "contextualAlternates", + "stylisticAlternates", + "stylisticSets", + "swash", + "titling", + "ordinals", + "fractions", + "allSmallCaps", + "figureStyle", + "figureWidth", + "figures", ]; /// Paragraph attribute keys of `layer.setText`. @@ -850,6 +993,7 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { @@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result s.ligatures = flag(key, v)?, "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, + "discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?, + "contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?, + "stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?, + "swash" => s.opentype.swash = flag(key, v)?, + "titling" => s.opentype.titling = flag(key, v)?, + "ordinals" => s.opentype.ordinals = flag(key, v)?, + "fractions" => s.opentype.fractions = flag(key, v)?, + "allSmallCaps" => { + s.opentype.all_small_caps = flag(key, v)?; + if s.opentype.all_small_caps { + s.all_caps = false; + } + } + "stylisticSets" => { + s.opentype.stylistic_sets = match v { + J::Array(a) => { + let mut m = 0u32; + for n in a { + let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?; + m |= 1 << (n - 1); + } + m + } + J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32, + _ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()), + } + } + "figureStyle" => { + s.opentype.figure_style = match norm(v).as_deref() { + Some("default") => FigureStyle::Default, + Some("lining") => FigureStyle::Lining, + Some("oldstyle") => FigureStyle::OldStyle, + _ => return Err("figureStyle: expected default|lining|oldStyle".into()), + } + } + "figureWidth" => { + s.opentype.figure_width = match norm(v).as_deref() { + Some("default") => FigureWidth::Default, + Some("proportional") => FigureWidth::Proportional, + Some("tabular") => FigureWidth::Tabular, + _ => return Err("figureWidth: expected default|proportional|tabular".into()), + } + } + "figures" => { + let (st, w) = match norm(v).as_deref() { + Some("default") => (FigureStyle::Default, FigureWidth::Default), + Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular), + Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional), + Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular), + Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional), + _ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()), + }; + s.opentype.figure_style = st; + s.opentype.figure_width = w; + } + k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::().is_ok_and(|n| (1..=20).contains(&n)) => { + let n = k[2..].parse::().unwrap_or(0); + s.opentype.set_stylistic_set(n, flag(key, v)?); + } _ => return Ok(false), } Ok(true) @@ -915,6 +1118,13 @@ pub fn char_style_json(s: &CharStyle) -> J { "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, + "discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates, + "stylisticAlternates": s.opentype.stylistic_alternates, + "stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::>(), + "swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions, + "allSmallCaps": s.opentype.all_small_caps, + "figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" }, + "figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" }, }) } @@ -1127,4 +1337,37 @@ mod tests { assert_eq!(word_at(t, 8), 7..10); assert_eq!(word_at(t, 6), 6..7); } + + #[test] + fn opentype_attributes_per_range_and_serde() { + let mut d = TextDoc::plain("Office 1/2 1st"); + // Untouched documents serialize without an OpenType block. + assert!(!serde_json::to_string(&d).unwrap().contains("opentype")); + d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap(); + d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap(); + d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap(); + assert!(d.style_at(0).opentype.stylistic_set(2)); + assert!(!d.style_at(8).opentype.stylistic_set(2)); + assert!(d.style_at(8).opentype.fractions); + assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular); + assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap()); + assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19)); + assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err()); + let f = d.style_at(8).opentype.feature_settings(); + assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1))); + let mut s = d.style_at(0); + s.opentype.contextual_alternates = false; + assert!(s.opentype.feature_settings().contains(&(*b"calt", 0))); + // Round trip, and the JSON form names the attributes as layer.setText does. + let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back, d); + let j = char_style_json(&d.style_at(0)); + assert_eq!(j["stylisticSets"], serde_json::json!([1, 20])); + assert_eq!(j["figureStyle"], "oldStyle"); + // All Small Caps and All Caps exclude each other. + let mut s = CharStyle::default(); + apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap(); + apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap(); + assert!(!s.all_caps && s.opentype.all_small_caps); + } } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c3a60f5d0..297a816c0 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -212,6 +212,21 @@ impl Face { underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)), } } + /// The OpenType layout features the face offers (GSUB and GPOS tags, sorted, deduplicated). + pub fn features(&self) -> Vec { + use skrifa::raw::TableProvider; + let Some(f) = self.font() else { return vec![] }; + let mut out: Vec = vec![]; + if let Ok(list) = f.gsub().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + if let Ok(list) = f.gpos().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + out.sort(); + out.dedup(); + out + } /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`). pub fn has_feature(&self, tag: &[u8; 4]) -> bool { use skrifa::raw::TableProvider; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 824b73730..3333cdd11 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -18,6 +18,8 @@ use std::sync::{Arc, Mutex, OnceLock}; use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; use unicode_bidi::{BidiInfo, Level}; +use effectcraft_keyframe::OpenType; + use crate::fonts::{self, FaceId, Resolved}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -81,6 +83,8 @@ pub struct TextStyle { pub script: Script, /// Baseline-to-baseline distance in pixels; None = auto (120% of the size). pub leading: Option, + /// OpenType features (stylistic sets, figures, fractions, all small caps…). + pub opentype: OpenType, } impl Default for TextStyle { @@ -104,6 +108,7 @@ impl Default for TextStyle { tsume: 0.0, script: Script::Normal, leading: None, + opentype: OpenType::default(), } } } @@ -188,6 +193,7 @@ impl Hash for TextStyle { self.manual_kern.map(f32::to_bits).hash(h); self.leading.map(f32::to_bits).hash(h); (self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h); + self.opentype.hash(h); } } @@ -342,6 +348,8 @@ struct ShapedGlyph { dy: f32, size: f32, run: usize, + /// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's). + rise: f32, } struct Item { @@ -360,6 +368,50 @@ fn upper_single(c: char) -> char { const SMALL_CAPS_SCALE: f32 = 0.78; +/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital), +/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior +/// glyph (`sups` / `subs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Form { + Normal, + FauxSmall, + TrueSmall, + TrueScript, +} + +/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the +/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback. +pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool { + static C: OnceLock>> = OnceLock::new(); + let cache = C.get_or_init(Default::default); + if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) { + return *v; + } + let f = fonts::face(face); + let v = f.has_feature(tag) + && match (f.font(), f.shaper_data()) { + (Some(font), Some(data)) => { + let shaper = data.shaper(&font).build(); + let shape = |feats: &[Feature]| -> Vec { + let mut buf = UnicodeBuffer::new(); + buf.add(c, 0); + buf.guess_segment_properties(); + shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect() + }; + let plain = shape(&[]); + let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]); + plain != with && !with.contains(&0) + } + _ => false, + }; + let mut m = cache.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 100_000 { + m.clear(); + } + m.insert((face, *tag, c), v); + v +} + /// Horizontal ink extent `(x0, x1)` of a glyph in font units. fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { let p = crate::outline_units(face, gid)?; @@ -370,7 +422,7 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { Some((r.x0 as f32, r.x1 as f32)) } -fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec { +fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec { let f = fonts::face(face); let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; let shaper = data.shaper(&font).build(); @@ -388,6 +440,30 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, feats.push(Feature::new(Tag::new(b"liga"), 0, ..)); feats.push(Feature::new(Tag::new(b"clig"), 0, ..)); } + // OpenType options; small caps go per character (true where the font has them). + for (tag, v) in style.opentype.feature_settings() { + if &tag != b"smcp" && &tag != b"c2sc" { + feats.push(Feature::new(Tag::new(&tag), v, ..)); + } + } + if style.caps == Caps::All { + // Case-sensitive forms (punctuation raised to sit with capitals). + feats.push(Feature::new(Tag::new(b"case"), 1, ..)); + } + let rise = match form { + Form::TrueScript => style.baseline_shift, + _ => style.rise(), + }; + match form { + Form::TrueSmall => { + feats.push(Feature::new(Tag::new(b"smcp"), 1, ..)); + if style.opentype.all_small_caps { + feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..)); + } + } + Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)), + _ => {} + } let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); let k = size / f.units_per_em(); let hs = style.h_scale; @@ -404,6 +480,7 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, dy: p.y_offset as f32 * k, size, run, + rise, }) .collect() } @@ -539,38 +616,54 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; let base_rtl = pinfo.level.is_rtl(); - // per char: (byte, char, shaped char, face, rtl, small caps, run) - let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text + // per char: (byte, char, shaped char, face, rtl, form, run) + let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text .char_indices() .map(|(b, c)| { let si = run_at(runs, base + b); let style = &runs[si].1; - let (sc, small) = match style.caps { - Caps::Normal => (c, false), - Caps::All => (upper_single(c), false), - Caps::Small if c.is_lowercase() => (upper_single(c), true), - Caps::Small => (c, false), - }; let primary = primaries[si].face; - let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) }; - (b, c, sc, face, bidi.levels[b].is_rtl(), small, si) + let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) }; + let all_small = style.opentype.all_small_caps && style.caps != Caps::All; + let small = (style.caps == Caps::Small || all_small) && c.is_lowercase(); + let small_cap = all_small && c.is_uppercase(); + let (sc, mut form) = match style.caps { + Caps::All => (upper_single(c), Form::Normal), + _ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall), + _ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall), + _ if small => (upper_single(c), Form::FauxSmall), + _ if small_cap => (c, Form::FauxSmall), + _ => (c, Form::Normal), + }; + let face = face_of(sc); + if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() { + let tag = if style.script == Script::Sub { b"subs" } else { b"sups" }; + if feature_substitutes(face, tag, sc) { + form = Form::TrueScript; + } + } + (b, c, sc, face, bidi.levels[b].is_rtl(), form, si) }) .collect(); // items: runs of equal (face, rtl, small, style) let mut items: Vec = Vec::new(); let mut i = 0; while i < chars.len() { - let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6); + let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6); let k0 = key(&chars[i]); let mut j = i + 1; while j < chars.len() && key(&chars[j]) == k0 { j += 1; } - let (face, rtl, small, si) = k0; + let (face, rtl, form, si) = k0; let style = &runs[si].1; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); - let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 }; - let mut glyphs = shape_item(&sub, rtl, face, size, style, si); + let size = match form { + Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE, + Form::TrueScript => style.size, + _ => style.glyph_size(), + }; + let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form); space_item(&mut glyphs, style, text, true); let end = if j < chars.len() { chars[j].0 } else { text.len() }; items.push(Item { range: chars[i].0..end, rtl, glyphs }); @@ -718,7 +811,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar face: g.face, id: g.id, x: pen + g.dx, - y: -g.dy - st.rise(), + y: -g.dy - g.rise, size: g.size, cluster: base + g.cluster, synth_bold: primaries[g.run].synth_bold || st.faux_bold, @@ -960,6 +1053,82 @@ mod tests { TextStyle { size, ..Default::default() } } + fn ids(text: &str, s: &TextStyle) -> Vec { + layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect() + } + + fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle { + let mut s = st(100.0); + f(&mut s.opentype); + s + } + + fn serif() -> TextStyle { + TextStyle { family: "Noto Serif".into(), ..st(100.0) } + } + + #[test] + fn bundled_faces_list_their_features() { + let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features(); + for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] { + assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}"); + } + let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features(); + for t in ["smcp", "c2sc", "onum", "lnum"] { + assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}"); + } + } + + #[test] + fn opentype_features_substitute_glyphs() { + let plain = st(100.0); + // Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures. + assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits"); + assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20"); + assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac"); + assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn"); + let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular)); + let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional)); + assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}"); + // Old-style figures (Noto Serif). + let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() }; + assert_ne!(ids("123", &serif()), ids("123", &old), "onum"); + // Contextual alternates are on by default; turning them off can only keep or change glyphs. + let no_calt = ot(|o| o.contextual_alternates = false); + assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len()); + } + + #[test] + fn true_small_caps_and_superscripts_with_faux_fallback() { + // Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size. + let sc = TextStyle { caps: Caps::Small, ..serif() }; + let l = layout_uncached("Ab", &sc, &ParagraphStyle::default()); + let upper_b = ids("B", &serif())[0]; + assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital"); + assert_eq!(l.glyphs[1].size, 100.0); + // Inter has no smcp: a capital scaled down. + let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default()); + assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]); + assert!((faux.glyphs[1].size - 78.0).abs() < 0.01); + // All Small Caps: capitals become small capitals too (c2sc). + let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() }; + let a = layout_uncached("AB", &all, &ParagraphStyle::default()); + assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]); + // Superscript: Inter's sups figures at full size on the baseline; letters it lacks are + // synthesized (smaller and raised). + let sup = TextStyle { script: Script::Super, ..st(100.0) }; + let s2 = layout_uncached("2", &sup, &ParagraphStyle::default()); + assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]); + assert_eq!(s2.glyphs[0].size, 100.0); + assert_eq!(s2.glyphs[0].y, 0.0); + let sq = layout_uncached("q", &sup, &ParagraphStyle::default()); + if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') { + assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]); + } + let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default()); + assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id); + } + #[test] fn kerning_and_ligatures_change_advances() { let kern = measure("AVAVAV", &st(100.0)); @@ -1110,7 +1279,11 @@ mod tests { let base = st(100.0); let sup = TextStyle { script: Script::Super, ..base.clone() }; let sub = TextStyle { script: Script::Sub, ..base.clone() }; - let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); + // A letter the font has no superior / inferior glyph for: synthesized. + let inter = fonts::resolve("Inter", "Regular").face; + let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap(); + let text: String = ['x', c, 'y', c].iter().collect(); + let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); let g = &l.glyphs; assert!((g[1].size - 58.3).abs() < 0.1); assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size"); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 26a659bd6..beef03822 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -161,6 +161,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { BaselineOption::Subscript => Script::Sub, }, leading: s.leading.map(|l| l as f32), + opentype: s.opentype, } } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fef958a73..61c0f215d 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -2,7 +2,7 @@ //! Character and Paragraph panels show and change the selected text (creating style runs); //! otherwise they apply to the whole selected text layer. -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -103,7 +103,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(json!({"font": fams[i]})); } y += 28.0; - let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0)); let spop = egui::Id::new("char-style-pop"); if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled { widgets::open_popup(ui, spop); @@ -232,6 +232,16 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("character.ligatures", lr, "Ligatures"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim); + // OpenType: a popup with the font's layout features (like the Character panel's OpenType menu). + let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0)); + let opop = egui::Id::new("char-opentype-pop"); + let n_on = doc.opentype.feature_settings().len(); + let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() }; + if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled { + widgets::open_popup(ui, opop); + } + app.auto.add("character.opentype", or, "OpenType"); + opentype_popup(app, ui, opop, pos2((or.max.x - 300.0).max(rect.min.x), or.max.y), &doc, &mut actions); // Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu). if doc.vertical { y += 22.0; @@ -267,6 +277,100 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The Character panel's OpenType popup: feature toggles (dimmed when the font lacks them), +/// figure styles and the twenty stylistic sets. Every row is an automation target +/// (`character.opentype.`), and every change is a `layer.setText` attribute. +fn opentype_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, doc: &TextDoc, actions: &mut Vec) { + let open_id = id.with("open"); + if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { + return; + } + let t = app.tokens; + let feats = effectcraft_engine::font_features(&doc.font, &doc.style); + let has = |tag: &str| feats.iter().any(|f| f == tag); + let o = doc.opentype; + let mut rects: Vec<(String, Rect, String)> = vec![]; + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(230.0); + ui.label(egui::RichText::new(format!("{} {}", doc.font, doc.style)).small().color(t.text_dim)); + let toggles: [(&str, &str, bool, &[&str]); 12] = [ + ("ligatures", "Standard Ligatures", doc.ligatures, &["liga", "clig"]), + ("discretionaryLigatures", "Discretionary Ligatures", o.discretionary_ligatures, &["dlig"]), + ("contextualAlternates", "Contextual Alternates", o.contextual_alternates, &["calt"]), + ("stylisticAlternates", "Stylistic Alternates", o.stylistic_alternates, &["salt"]), + ("swash", "Swash", o.swash, &["swsh"]), + ("titling", "Titling Alternates", o.titling, &["titl"]), + ("ordinals", "Ordinals", o.ordinals, &["ordn"]), + ("fractions", "Fractions", o.fractions, &["frac"]), + ("smallCaps", "Small Caps", doc.small_caps, &["smcp"]), + ("allSmallCaps", "All Small Caps", o.all_small_caps, &["c2sc"]), + ("superscript", "Superscript / Superior", doc.baseline == BaselineOption::Superscript, &["sups"]), + ("subscript", "Subscript / Inferior", doc.baseline == BaselineOption::Subscript, &["subs"]), + ]; + for (key, label, on, tags) in toggles { + let avail = tags.iter().any(|g| has(g)); + // Unavailable features stay clickable: small caps and scripts are synthesized. + let synth = matches!(key, "smallCaps" | "allSmallCaps" | "superscript" | "subscript"); + let text = match (avail, synth) { + (true, _) => label.to_string(), + (false, true) => format!("{label} (synthesized)"), + (false, false) => format!("{label} (not in font)"), + }; + let r = ui.selectable_label(on, egui::RichText::new(text).color(if avail { t.text } else { t.text_faint })); + rects.push((format!("character.opentype.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({key: !on})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Figures").small().color(t.text_dim)); + let figs = [ + ("default", "Default Figure Style", FigureStyle::Default, FigureWidth::Default), + ("tabularLining", "Tabular Lining", FigureStyle::Lining, FigureWidth::Tabular), + ("proportionalOldstyle", "Proportional Oldstyle", FigureStyle::OldStyle, FigureWidth::Proportional), + ("proportionalLining", "Proportional Lining", FigureStyle::Lining, FigureWidth::Proportional), + ("tabularOldstyle", "Tabular Oldstyle", FigureStyle::OldStyle, FigureWidth::Tabular), + ]; + for (key, label, st, fw) in figs { + let on = o.figure_style == st && o.figure_width == fw; + let r = ui.selectable_label(on, label); + rects.push((format!("character.opentype.figures.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({"figures": key})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Stylistic Sets").small().color(t.text_dim)); + egui::Grid::new(id.with("sets")).spacing(vec2(3.0, 3.0)).show(ui, |ui| { + for n in 1..=20u32 { + let key = format!("ss{n:02}"); + let on = o.stylistic_set(n); + let avail = has(&key); + let r = ui.add_sized( + vec2(38.0, 20.0), + egui::Button::selectable(on, egui::RichText::new(format!("{n}")).color(if avail || on { t.text } else { t.text_faint })), + ); + rects.push((format!("character.opentype.{key}"), r.rect, format!("Stylistic Set {n}"))); + if r.clicked() { + actions.push(json!({key: !on})); + } + if n % 5 == 0 { + ui.end_row(); + } + } + }); + }); + }); + for (aid, r, label) in rects { + app.auto.add(&aid, r, &label); + } + let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered(); + if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(open_id, false)); + } +} + /// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key). const ALIGNS: [(Justify, &str); 7] = [ (Justify::Left, "left"), From da0ec526f3736c895d4e2cfdf261d314bcf33859 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:12:21 -0400 Subject: [PATCH 17/20] M13.2: PDF / Illustrator (.ai) / EPS vector footage (effectcraft-pdf): continuous rasterisation, layered composition import, Create Shapes from Vector Layer; SVG render tree clip groups Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/commands/create.rs | 8 +- crates/engine/src/commands/file.rs | 33 +- crates/engine/src/psd_import.rs | 2 +- crates/engine/src/vector.rs | 73 +- crates/host/Cargo.toml | 1 + crates/host/tests/vector_import.rs | 113 ++ crates/media/Cargo.toml | 1 + crates/media/src/layered.rs | 46 +- crates/media/src/lib.rs | 3 +- crates/media/src/pool.rs | 2 +- crates/pdf/Cargo.toml | 15 + crates/pdf/README.md | 48 + crates/pdf/src/build.rs | 93 ++ crates/pdf/src/color.rs | 288 +++++ crates/pdf/src/lib.rs | 185 ++++ crates/pdf/src/object.rs | 708 +++++++++++++ crates/pdf/src/page.rs | 573 ++++++++++ crates/pdf/src/ps.rs | 1462 ++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 150 +++ crates/pdf/src/write.rs | 32 + crates/project/src/lib.rs | 11 +- crates/render/src/lib.rs | 2 +- crates/svg/src/lib.rs | 15 +- crates/svg/src/raster.rs | 21 + crates/ui-egui/src/menus.rs | 2 +- xtask/src/main.rs | 2 + 29 files changed, 3875 insertions(+), 28 deletions(-) create mode 100644 crates/pdf/Cargo.toml create mode 100644 crates/pdf/README.md create mode 100644 crates/pdf/src/build.rs create mode 100644 crates/pdf/src/color.rs create mode 100644 crates/pdf/src/lib.rs create mode 100644 crates/pdf/src/object.rs create mode 100644 crates/pdf/src/page.rs create mode 100644 crates/pdf/src/ps.rs create mode 100644 crates/pdf/src/tests.rs create mode 100644 crates/pdf/src/write.rs diff --git a/Cargo.lock b/Cargo.lock index c79341009..87cd95d1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,6 +1213,7 @@ dependencies = [ "effectcraft-lottie", "effectcraft-model", "effectcraft-path", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1309,6 +1310,7 @@ dependencies = [ "effectcraft-export", "effectcraft-expr", "effectcraft-media", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1352,6 +1354,7 @@ name = "effectcraft-media" version = "0.1.0" dependencies = [ "effectcraft-model", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1398,6 +1401,15 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "effectcraft-pdf" +version = "0.1.0" +dependencies = [ + "effectcraft-svg", + "kurbo", + "miniz_oxide 0.8.9", +] + [[package]] name = "effectcraft-project" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 22709f60d..cda7c302c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-lottie = { path = "crates/lottie" } +effectcraft-pdf = { path = "crates/pdf" } effectcraft-psd = { path = "crates/psd" } effectcraft-vp9enc = { path = "crates/vp9enc" } effectcraft-svg = { path = "crates/svg" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 45508a716..7a7adb30b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ effectcraft-lottie = { workspace = true } effectcraft-psd = { workspace = true } kurbo = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index 72cb2420f..a1d22fb59 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -28,12 +28,14 @@ fn create(s: &mut Session, p: &Value) -> Result { }); match op { "shapesFromVector" => { - let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else { - skipped.push(format!("{}: not a vector (SVG) footage layer", src.name)); + let Some(f) = footage.filter(crate::vector::is_vector) else { + skipped.push(format!("{}: not a vector (SVG, PDF, AI or EPS) footage layer", src.name)); continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref()) + .unwrap_or_else(|| Err(format!("{}: not a vector file", f.path))) + .map_err(EngineError::Other)?; let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| { let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc); let id = l.id; diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 5a69a213b..661169e0b 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -101,6 +101,17 @@ fn import(s: &mut Session, p: &Value) -> Result { for path in paths { let bytes = match s.services.read_file(&path) { Ok(b) if effectcraft_psd::is_psd(&b) => b, + Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { + // PDF / Illustrator / EPS: one layer per file layer. + match import_vector_comp(s, &path, &b) { + Ok((comp, items)) => { + out_comps.push(comp); + ids.extend(items); + } + Err(e) => errors.push(e.to_string()), + } + continue; + } _ => { rest.push(path); continue; @@ -153,7 +164,9 @@ fn import(s: &mut Session, p: &Value) -> Result { && f.codec == "PSD" { match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) { - Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }), + Some((index, name)) => { + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None }) + } None => { errors.push(format!("{path}: no layer {sel}")); continue; @@ -217,6 +230,22 @@ fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Ok((r.comp.0, items, r.warnings)) } +/// Import a PDF / Illustrator / EPS file as a composition (one undo step). Returns (comp, items). +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8]) -> Result<(u64, Vec)> { + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); + let rate = effectcraft_time::FrameRate::FPS_29_97; + let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; + let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); + let (comp, folder, items) = s.edit("Import", None, |proj, st| { + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?; + st.project_selection = vec![r.0]; + Ok(r) + })?; + let mut out = vec![folder.0]; + out.extend(items.iter().map(|i| i.0)); + Ok((comp.0, out)) +} + /// Extensions imported as data footage. pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"]; @@ -381,7 +410,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer)}", always, import ), diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index f64fd3af1..2f012eee6 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -156,7 +156,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( missing: false, sequence: vec![], color_profile: None, - layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }), ..Default::default() }; let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index a3f3e040a..3465d3855 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -1,5 +1,6 @@ -//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage → -//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines). +//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF, +//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from +//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer). use effectcraft_keyframe::{Gradient, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; @@ -149,6 +150,74 @@ pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } } +/// Whether footage is a vector file (SVG / PDF / AI / EPS). +pub fn is_vector(f: &effectcraft_project::Footage) -> bool { + effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg") +} + +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to one layer when `layer` names it. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage +/// item per file layer (each showing only that layer, continuously rasterisable), top layer +/// first. Returns (comp, folder, items). +pub fn import_vector_comp( + proj: &mut Project, + path: &str, + bytes: &[u8], + name: &str, + rate: effectcraft_time::FrameRate, + duration: effectcraft_time::Tick, +) -> Result<(ItemId, ItemId, Vec), String> { + use effectcraft_color::Label; + use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?; + let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); + let (w, h) = doc.pixel_size(); + let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); + let mut comp = Comp::new(w, h, rate, duration); + let mut items = vec![]; + let names = effectcraft_pdf::layer_names(&doc); + for (i, lname) in names.iter().enumerate() { + let f = Footage { + path: path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: rate, + has_video: true, + alpha: AlphaMode::Straight, + loop_count: 1, + codec: codec.into(), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + ..Default::default() + }; + let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); + items.push(id); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + l.name = comp.unique_layer_name(lname); + comp.layers.insert(0, l); + } + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp))); + Ok((cid, folder, items)) +} + /// A copy of `src`'s transform group with fresh uids. fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) { let Some(tr) = src.props.sub("transform") else { return }; diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 635e32605..564efb3b7 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -21,5 +21,6 @@ effectcraft-export = { workspace = true } serde_json = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 590112c9f..7b516a2a3 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -184,3 +184,116 @@ fn svg_footage_continuously_rasterizes() { assert!(sharp < 0.004, "continuously rasterised diff {sharp}"); assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})"); } + +/// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky, +/// a red curved shape with a dark outline, and a clipped green bar. +fn layered_pdf() -> Vec { + let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\ +/OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\ +q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n"; + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>"; + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, page.into(), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /OCG /Name (Sky) >>".into(), None), + (6, "<< /Type /OCG /Name (Art) >>".into(), None), + (7, pattern.into(), None), + ], + 1, + ) +} + +#[test] +fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { + let bytes = layered_pdf(); + let doc = effectcraft_pdf::parse(&bytes).unwrap(); + let (w, h) = doc.pixel_size(); + assert_eq!((w, h), (120, 80)); + let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); + for name in ["art.pdf", "art.ai"] { + let path = tmp(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() }; + assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" }); + assert_eq!((f.width, f.height), (120, 80)); + s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = effectcraft_time::Tick::ZERO; + let foot = s.render(cid, t, RenderOpts::default()); + assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised page"); + // Continuously Rasterize at 300 %. + for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] { + s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); + } + let want = effectcraft_svg::rasterize(&doc, w, h, 3.0); + let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); + let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}"); + s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap(); + // Create Shapes from Vector Layer. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.layers[0].id.0, sid); + let shapes = s.render(cid, t, RenderOpts::default()); + // Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in + // full; everything else matches. + let rows = |img: &Image| Image { + width: w, + height: h - 15, + data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(), + }; + let d = mean_diff(&rows(&shapes), &rows(&direct)); + assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); + let fill = shapes.get(60, 30); + assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); + } + // Import As: Composition — one layer per file layer, top first, together like the page. + let path = tmp("layers.ai"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); +} + +#[test] +fn eps_footage_imports() { + let eps = + b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n"; + let path = tmp("shape.eps"); + std::fs::write(&path, eps).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10)); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 10998031f..3db4b1a96 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -16,6 +16,7 @@ effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 204312a58..111140a4c 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -1,5 +1,7 @@ //! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one -//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised). +//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG +//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for +//! footage imported as a composition). use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::Image; @@ -11,7 +13,29 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) } -/// Footage for a Photoshop or SVG file (`None` for other formats). +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to the footage's layer when it names one. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if is_svg(path, bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) +} + +/// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -28,6 +52,14 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { f.alpha = AlphaMode::Straight; return Ok(Some(f)); } + if is_pdf_like(path, bytes) { + let doc = effectcraft_pdf::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let (w, h) = doc.pixel_size(); + let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true); + f.codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF").into(); + f.alpha = AlphaMode::Straight; + return Ok(Some(f)); + } Ok(None) } @@ -42,8 +74,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - .map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } - if is_svg(path, bytes) { - let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + if is_svg(path, bytes) || is_pdf_like(path, bytes) { + let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. @@ -53,9 +85,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } -/// An SVG rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_svg(bytes: &[u8], scale: f64) -> Option { - let doc = effectcraft_svg::parse(bytes).ok()?; +/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option { + let doc = vector_doc(path, bytes, layer)?.ok()?; let (w, h) = doc.pixel_size(); let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6c67f50f5..5de2eb8cf 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ mod convert; pub mod exr_channels; mod layered; +pub use layered::vector_doc; mod pool; mod probe; @@ -47,7 +48,7 @@ impl From for MediaError { } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9bdefcb69..ed6521c91 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -516,7 +516,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_svg(&bytes, scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new) } fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 000000000..93d082b5c --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "effectcraft-pdf" +description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-svg = { workspace = true } +kurbo = { workspace = true } +miniz_oxide = "0.8" diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 000000000..a1a9d0b3e --- /dev/null +++ b/crates/pdf/README.md @@ -0,0 +1,48 @@ +# effectcraft-pdf + +Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, which are PDF +documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG +(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape +layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one +layer per file layer (File ▸ Import ▸ Composition). + +Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file +structure, filters, content streams, graphics state, colour spaces, functions, shadings and +optional content; the **PostScript Language Reference, third edition** and the **Encapsulated +PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read; +test files are generated in code ([`write`](src/write.rs) and the tests). + +## What is read + +- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference + sections do not matter), object streams, the page tree with inherited resources, MediaBox / + CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, + RunLength. Encrypted files are refused. +- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`, + `LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd), + clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by + component count), CalGray / CalRGB, Lab, Indexed, Separation and DeviceN (tint transforms of + function types 0, 2 and 3), axial and radial shadings as pattern fills and through `sh`, form + XObjects (matrix, bounding-box clip, nested resources). +- **Layers:** top-level optional-content marked sequences (`/OC … BDC`, Illustrator's layers) + become the document's layers, named after their OCG; content outside them is gathered into + layers "Layer N". +- **EPS:** a PostScript interpreter for the subset exported vector EPS uses: operand and + dictionary stacks, procedures and `def`/`bind`/`load`/`where`, arithmetic, comparisons, + `if`/`ifelse`/`for`/`repeat`/`loop`/`forall`, arrays and strings, `gsave`/`grestore`, + `save`/`restore`, matrices (`concat`, `translate`, `scale`, `rotate`, `transform` …), path + construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`, + `clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS + binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` / + `%%BeginBinary` / preview sections are skipped. + +## Limitations + +- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings + (types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a + document skipped is listed in `Doc::skipped`. +- Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in + the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw + only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead. +- Shape-layer conversion has no clipping: clipped artwork converts unclipped. +- Only the first page of a multi-page PDF is imported. diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs new file mode 100644 index 000000000..ca063dee8 --- /dev/null +++ b/crates/pdf/src/build.rs @@ -0,0 +1,93 @@ +//! The output side shared by the PDF and PostScript interpreters: an open-group stack (clipping +//! groups, optional-content layers) that collects shapes into the SVG render tree. + +use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Plain, + Clip, + Layer, +} + +pub(crate) struct Builder { + stack: Vec<(Group, Kind)>, + /// Which of the finished top-level nodes are optional-content layers. + pub top_layers: Vec, + pub skipped: Vec, + pub shapes: usize, +} + +pub(crate) fn group(name: &str) -> Group { + Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] } +} + +impl Builder { + pub fn new() -> Builder { + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + } + + pub fn depth(&self) -> usize { + self.stack.len() + } + + pub fn skip(&mut self, what: &str) { + if !self.skipped.iter().any(|s| s == what) { + self.skipped.push(what.to_string()); + } + } + + fn add(&mut self, n: Node, layer: bool) { + if self.stack.len() == 1 { + self.top_layers.push(layer); + } + if let Some((g, _)) = self.stack.last_mut() { + g.children.push(n); + } + } + + /// Close every group opened above `depth`. + pub fn close_to(&mut self, depth: usize) { + while self.stack.len() > depth.max(1) { + let Some((g, k)) = self.stack.pop() else { break }; + // Empty clipping groups draw nothing. + if g.children.is_empty() { + continue; + } + self.add(Node::Group(g), k == Kind::Layer); + } + } + + /// Children drawn from now on are clipped by `path` (page space) until the group closes. + pub fn clip(&mut self, path: BezPath, rule: FillRule) { + let mut g = group("Clip Group"); + g.clip.push((path, rule)); + self.stack.push((g, Kind::Clip)); + } + + /// Open an optional-content layer (or a named group). + pub fn open(&mut self, name: &str, layer: bool) { + self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + } + + pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { + if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) { + return; + } + self.shapes += 1; + let (f, fo, rule) = match fill { + Some((p, o, r)) => (Some(p), o, r), + None => (None, 1.0, FillRule::NonZero), + }; + let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; + self.add(Node::Shape(s), false); + } + + /// The finished top-level nodes and their layer flags. + pub fn finish(mut self) -> (Vec, Vec, Vec) { + self.close_to(1); + let root = self.stack.pop().map(|(g, _)| g.children).unwrap_or_default(); + let flags = self.top_layers; + (root, flags, self.skipped) + } +} diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs new file mode 100644 index 000000000..b35e9f39f --- /dev/null +++ b/crates/pdf/src/color.rs @@ -0,0 +1,288 @@ +//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, +//! exponential and stitching; PostScript calculator functions are not evaluated). + +use crate::object::{Dict, File, Obj, decode_stream}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Cs { + Gray, + Rgb, + Cmyk, + /// CIE L*a*b* (approximated). + Lab, + Indexed { + base: Box, + hival: usize, + lookup: Vec, + }, + /// Separation / DeviceN: components → alternate space through a tint transform. + Tint { + n: usize, + alt: Box, + func: Func, + }, + Pattern, +} + +impl Cs { + pub fn components(&self) -> usize { + match self { + Cs::Gray | Cs::Indexed { .. } => 1, + Cs::Rgb | Cs::Lab => 3, + Cs::Cmyk => 4, + Cs::Tint { n, .. } => *n, + Cs::Pattern => 0, + } + } + + /// The initial colour of the space (black, or tint 1). + pub fn initial(&self) -> Vec { + match self { + Cs::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Cs::Tint { n, .. } => vec![1.0; *n], + Cs::Lab => vec![0.0, 0.0, 0.0], + c => vec![0.0; c.components()], + } + } + + pub fn to_rgb(&self, c: &[f64]) -> [f64; 3] { + let g = |i: usize| c.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0); + match self { + Cs::Gray => [g(0); 3], + Cs::Rgb | Cs::Pattern => [g(0), g(1), g(2)], + Cs::Cmyk => { + let k = g(3); + [(1.0 - g(0)) * (1.0 - k), (1.0 - g(1)) * (1.0 - k), (1.0 - g(2)) * (1.0 - k)] + } + Cs::Lab => lab_to_rgb(c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)), + Cs::Indexed { base, hival, lookup } => { + let i = (c.first().copied().unwrap_or(0.0).round().max(0.0) as usize).min(*hival); + let n = base.components(); + let v: Vec = (0..n).map(|k| lookup.get(i * n + k).copied().unwrap_or(0) as f64 / 255.0).collect(); + base.to_rgb(&v) + } + Cs::Tint { alt, func, .. } => alt.to_rgb(&func.eval(c)), + } + } +} + +fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let f = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let (x, y, z) = (0.9505 * f(fx), f(fy), 1.089 * f(fz)); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.204 * y + 1.057 * z]; + lin.map(|v| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are +/// looked up in `res` (`/ColorSpace`). +pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { + let o = file.resolve(o); + match o { + Obj::Name(n) => match n.as_str() { + "DeviceGray" | "G" | "CalGray" => Cs::Gray, + "DeviceRGB" | "RGB" | "CalRGB" => Cs::Rgb, + "DeviceCMYK" | "CMYK" => Cs::Cmyk, + "Pattern" => Cs::Pattern, + other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) { + Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None), + _ => Cs::Gray, + }, + }, + Obj::Array(a) => { + let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or(""); + match head { + "ICCBased" => { + let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; + match n { + 1 => Cs::Gray, + 4 => Cs::Cmyk, + _ => Cs::Rgb, + } + } + "CalRGB" => Cs::Rgb, + "CalGray" => Cs::Gray, + "Lab" => Cs::Lab, + "Pattern" => Cs::Pattern, + "Indexed" | "I" => { + let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb); + let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize; + let lookup = match a.get(3).map(|l| file.resolve(l)) { + Some(Obj::Str(s)) => s.clone(), + Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(), + _ => vec![], + }; + Cs::Indexed { base: Box::new(base), hival, lookup } + } + "Separation" | "DeviceN" => { + let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) }; + let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray); + let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported); + // An unreadable tint transform: tint 1 = black, 0 = white. + if func == Func::Unsupported { + return Cs::Tint { n, alt: Box::new(Cs::Gray), func: Func::Exp { domain: [0.0, 1.0], c0: vec![1.0], c1: vec![0.0], n: 1.0 } }; + } + Cs::Tint { n, alt: Box::new(alt), func } + } + _ => Cs::Rgb, + } + } + _ => Cs::Gray, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Func { + /// Type 2. + Exp { + domain: [f64; 2], + c0: Vec, + c1: Vec, + n: f64, + }, + /// Type 3. + Stitch { + domain: [f64; 2], + funcs: Vec, + bounds: Vec, + encode: Vec, + }, + /// Type 0 with one input. + Sampled { + domain: [f64; 2], + size: usize, + outputs: usize, + encode: [f64; 2], + decode: Vec, + samples: Vec, + }, + /// An array of one-output functions. + Array(Vec), + Unsupported, +} + +impl Func { + pub fn read(file: &File, o: &Obj) -> Func { + let o = file.resolve(o); + if let Obj::Array(a) = o { + return Func::Array(a.iter().map(|f| Func::read(file, f)).collect()); + } + let Some(d) = o.dict() else { return Func::Unsupported }; + let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); + let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)]; + match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 { + 2 => Func::Exp { + domain, + c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]), + c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), + n: file.get_num(d, "N").unwrap_or(1.0), + }, + 3 => Func::Stitch { + domain, + funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(), + bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), + encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + }, + 0 => { + let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; + let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; + let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize; + let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize; + let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(); + let outputs = range.len() / 2; + if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) { + return Func::Unsupported; + } + let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); + let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)]; + let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone()); + let max = ((1u64 << bps) - 1) as f64; + let mut samples = Vec::with_capacity(size * outputs); + let mut bit = 0usize; + for _ in 0..size * outputs { + let mut v = 0u64; + for _ in 0..bps { + let byte = data.get(bit / 8).copied().unwrap_or(0); + v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64; + bit += 1; + } + samples.push(v as f64 / max); + } + Func::Sampled { domain, size, outputs, encode, decode, samples } + } + _ => Func::Unsupported, + } + } + + /// Evaluate at the first input (the functions used by shadings and tints take one input + /// in practice; extra inputs of DeviceN tints are averaged in). + pub fn eval(&self, input: &[f64]) -> Vec { + let t = input.first().copied().unwrap_or(0.0); + self.eval1(t) + } + + pub fn eval1(&self, t: f64) -> Vec { + match self { + Func::Exp { domain, c0, c1, n } => { + let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1])); + let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) }; + (0..c0.len().max(c1.len())) + .map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0))) + .collect() + } + Func::Stitch { domain, funcs, bounds, encode } => { + if funcs.is_empty() { + return vec![0.0]; + } + let t = t.clamp(domain[0], domain[1]); + let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1); + let lo = if k == 0 { domain[0] } else { bounds[k - 1] }; + let hi = bounds.get(k).copied().unwrap_or(domain[1]); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if hi > lo { e0 + (t - lo) / (hi - lo) * (e1 - e0) } else { e0 }; + funcs[k].eval1(u) + } + Func::Sampled { domain, size, outputs, encode, decode, samples } => { + let t = t.clamp(domain[0], domain[1]); + let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] }; + let e = e.clamp(0.0, *size as f64 - 1.0); + let (i0, f) = (e.floor() as usize, e.fract()); + let i1 = (i0 + 1).min(size - 1); + (0..*outputs) + .map(|o| { + let s = samples[i0 * outputs + o] * (1.0 - f) + samples[i1 * outputs + o] * f; + let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0)); + d0 + s * (d1 - d0) + }) + .collect() + } + Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), + Func::Unsupported => vec![0.5], + } + } + + /// Input values worth sampling exactly (stitching bounds). + pub fn breakpoints(&self) -> Vec { + match self { + Func::Stitch { bounds, .. } => bounds.clone(), + _ => vec![], + } + } +} + +/// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1). +pub fn text_string(b: &[u8]) -> String { + if b.starts_with(&[0xFE, 0xFF]) { + let u: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + return String::from_utf16_lossy(&u); + } + if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + b.iter().map(|&c| c as char).collect() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 000000000..cd835113e --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,185 @@ +//! Vector footage: PDF, PDF-compatible Illustrator files (`.ai`) and Encapsulated PostScript +//! (see README.md for what is read and the specifications used). +//! +//! Files parse into the same render tree as SVG ([`effectcraft_svg::Doc`]): document pixels are +//! PostScript points (1/72 in), y down, so footage rasterises at any scale (Continuously +//! Rasterize) and converts to shape layers (Create Shapes from Vector Layer). The root group's +//! children are the document's **layers**: one group per top-level optional-content group +//! (Illustrator layers), with content outside them gathered into groups of its own. + +mod build; +mod color; +mod object; +mod page; +mod ps; +pub mod write; + +pub use effectcraft_svg::Doc; +use effectcraft_svg::{Affine, Group, Node}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + NotVector, + Encrypted, + NoPages, + PageOutOfRange(usize), + NoBoundingBox, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"), + Error::Encrypted => write!(f, "encrypted PDF files are not supported"), + Error::NoPages => write!(f, "the PDF has no pages"), + Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1), + Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"), + } + } +} + +impl std::error::Error for Error {} + +/// File extensions read as vector footage by this crate. +pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"]; + +/// The format of a file, by its first bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Pdf, + Eps, +} + +/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file. +pub fn sniff(bytes: &[u8]) -> Option { + let head = &bytes[..bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + return Some(Format::Pdf); + } + if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) || bytes.starts_with(b"%!PS") { + return Some(Format::Eps); + } + None +} + +/// A footage codec name for a sniffed file ("PDF", "AI", "EPS"). +pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> { + let f = sniff(bytes)?; + let ai = path.to_ascii_lowercase().ends_with(".ai"); + Some(match (f, ai) { + (_, true) => "AI", + (Format::Pdf, false) => "PDF", + (Format::Eps, false) => "EPS", + }) +} + +/// Parse the first page of a PDF / `.ai`, or an EPS file. +pub fn parse(bytes: &[u8]) -> Result { + parse_page(bytes, 0) +} + +/// Parse page `index` of a PDF (EPS files have one page). +pub fn parse_page(bytes: &[u8], index: usize) -> Result { + match sniff(bytes) { + Some(Format::Pdf) => parse_pdf(bytes, index), + Some(Format::Eps) => parse_eps(bytes), + None => Err(Error::NotVector), + } +} + +/// Number of pages (1 for EPS). +pub fn page_count(bytes: &[u8]) -> usize { + match sniff(bytes) { + Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(), + Some(Format::Eps) => 1, + None => 0, + } +} + +/// Build the document: `root` children become layer groups (see the crate docs). +fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, height: f64, to_doc: Affine) -> Doc { + let mut layers: Vec = vec![]; + let mut loose: Vec = vec![]; + let flush = |loose: &mut Vec, layers: &mut Vec| { + if !loose.is_empty() { + let mut g = build::group(""); + g.children = std::mem::take(loose); + layers.push(Node::Group(g)); + } + }; + for (n, is_layer) in nodes.into_iter().zip(flags.into_iter().chain(std::iter::repeat(false))) { + if is_layer { + flush(&mut loose, &mut layers); + layers.push(n); + } else { + loose.push(n); + } + } + flush(&mut loose, &mut layers); + // Name unnamed layers "Layer N" (bottom first, as Illustrator numbers them). + for (i, l) in layers.iter_mut().enumerate() { + if let Node::Group(g) = l + && g.name.is_empty() + { + g.name = format!("Layer {}", i + 1); + } + } + Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped } +} + +fn parse_pdf(bytes: &[u8], index: usize) -> Result { + let file = object::File::parse(bytes); + if file.encrypted() { + return Err(Error::Encrypted); + } + let pages = page::pages(&file); + if pages.is_empty() { + return Err(Error::NoPages); + } + let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?; + let [x0, y0, x1, y1] = pg.bbox; + let (w, h) = (x1 - x0, y1 - y0); + // Default user space (y up) → document pixels (y down), with the page's /Rotate. + let (to_doc, dw, dh) = match pg.rotate { + 90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w), + 180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h), + 270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w), + _ => (Affine::new([1.0, 0.0, 0.0, -1.0, -x0, y1]), w, h), + }; + let content = page::page_content(&file, pg); + let mut it = page::Interp::new(&file, pg.bbox); + it.run(&content, &pg.resources, Affine::IDENTITY, 0); + let (nodes, flags, skipped) = it.b.finish(); + Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc)) +} + +fn parse_eps(bytes: &[u8]) -> Result { + let (ps, bb) = ps::eps_parts(bytes).ok_or(Error::NoBoundingBox)?; + let mut it = ps::Interp::new(bb); + it.run(ps); + let (nodes, flags, skipped) = it.b.finish(); + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -bb[0], bb[3]]); + Ok(finish(nodes, flags, skipped, (bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0), to_doc)) +} + +/// The document's layer names (the root's children), bottom first. +pub fn layer_names(doc: &Doc) -> Vec { + doc.root + .children + .iter() + .map(|n| match n { + Node::Group(g) => g.name.clone(), + Node::Shape(s) => s.name.clone(), + }) + .collect() +} + +/// The document with only layer `index` (for one layer of a file imported as a composition). +pub fn layer_doc(doc: &Doc, index: usize) -> Doc { + let mut d = doc.clone(); + d.root.children = doc.root.children.get(index).cloned().into_iter().collect(); + d +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs new file mode 100644 index 000000000..e24f6ec8a --- /dev/null +++ b/crates/pdf/src/object.rs @@ -0,0 +1,708 @@ +//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the +//! indirect-object table. +//! +//! The table is built by scanning the file for `n g obj` headers (later definitions win, as +//! incremental updates do) and by unpacking object streams, so damaged or rewritten +//! cross-reference sections do not matter. + +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq)] +pub enum Obj { + Null, + Bool(bool), + Num(f64), + Name(String), + Str(Vec), + Array(Vec), + Dict(Dict), + Ref(u32, u16), + /// A stream: its dictionary and raw (still encoded) data. + Stream(Dict, Vec), + /// A content-stream operator (or any bare keyword). + Op(String), +} + +pub type Dict = HashMap; + +impl Obj { + pub fn num(&self) -> Option { + match self { + Obj::Num(n) => Some(*n), + _ => None, + } + } + pub fn name(&self) -> Option<&str> { + match self { + Obj::Name(n) => Some(n), + _ => None, + } + } + pub fn array(&self) -> Option<&[Obj]> { + match self { + Obj::Array(a) => Some(a), + _ => None, + } + } + pub fn dict(&self) -> Option<&Dict> { + match self { + Obj::Dict(d) | Obj::Stream(d, _) => Some(d), + _ => None, + } + } +} + +pub fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} +pub fn is_delim(c: u8) -> bool { + matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +/// A byte lexer producing PDF objects (arrays and dictionaries nested). +pub struct Lexer<'a> { + pub data: &'a [u8], + pub pos: usize, +} + +impl<'a> Lexer<'a> { + pub fn new(data: &'a [u8], pos: usize) -> Self { + Lexer { data, pos } + } + + pub fn skip_ws(&mut self) { + while self.pos < self.data.len() { + let c = self.data[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + } else { + break; + } + } + } + + fn word(&mut self) -> &'a [u8] { + let s = self.pos; + while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) { + self.pos += 1; + } + &self.data[s..self.pos] + } + + /// The next object, `None` at the end. `]` and `>>` come back as `Op`s. + pub fn next(&mut self) -> Option { + self.next_depth(0) + } + + fn next_depth(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.data.get(self.pos)?; + match c { + b'/' => { + self.pos += 1; + let w = self.word(); + Some(Obj::Name(decode_name(w))) + } + b'(' => Some(Obj::Str(self.literal_string())), + b'<' if self.data.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + let mut d = Dict::new(); + loop { + if depth > 64 { + return Some(Obj::Null); + } + let k = self.next_depth(depth + 1); + match k { + None => break, + Some(Obj::Op(o)) if o == ">>" => break, + Some(Obj::Name(k)) => { + let v = self.next_depth(depth + 1).unwrap_or(Obj::Null); + let v = self.maybe_ref(v); + d.insert(k, v); + } + Some(_) => {} + } + } + Some(Obj::Dict(d)) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.data.len() && self.data[self.pos] != b'>' { + let h = self.data[self.pos]; + if h.is_ascii_hexdigit() { + hex.push(h); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + Some(Obj::Str(hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect())) + } + b'>' if self.data.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + Some(Obj::Op(">>".into())) + } + b'[' => { + self.pos += 1; + let mut a = vec![]; + loop { + if depth > 64 { + return Some(Obj::Null); + } + match self.next_depth(depth + 1) { + None => break, + Some(Obj::Op(o)) if o == "]" => break, + Some(v) => { + let v = self.maybe_ref(v); + a.push(v); + } + } + } + Some(Obj::Array(a)) + } + b']' | b'{' | b'}' | b')' | b'>' => { + self.pos += 1; + Some(Obj::Op((c as char).to_string())) + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(Obj::Op(String::new())); + } + Some(match w { + b"true" => Obj::Bool(true), + b"false" => Obj::Bool(false), + b"null" => Obj::Null, + _ => match parse_num(w) { + Some(n) => Obj::Num(n), + None => Obj::Op(String::from_utf8_lossy(w).into_owned()), + }, + }) + } + } + } + + /// `n g R` after a number inside arrays and dictionaries. + fn maybe_ref(&mut self, v: Obj) -> Obj { + let Obj::Num(n) = v else { return v }; + if n.fract() != 0.0 || n < 0.0 { + return v; + } + let save = self.pos; + self.skip_ws(); + let gp = self.pos; + let g = self.word(); + if let Some(gn) = parse_num(g).filter(|g| g.fract() == 0.0 && *g >= 0.0) { + self.skip_ws(); + if self.data.get(self.pos) == Some(&b'R') && self.data.get(self.pos + 1).is_none_or(|c| is_white(*c) || is_delim(*c)) { + self.pos += 1; + return Obj::Ref(n as u32, gn as u16); + } + } + let _ = gp; + self.pos = save; + v + } + + fn literal_string(&mut self) -> Vec { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.data.len() { + let c = self.data[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let Some(&e) = self.data.get(self.pos) else { break }; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + b'\r' => { + if self.data.get(self.pos) == Some(&b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.data.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + other => out.push(other), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + out + } +} + +fn hexv(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => 0, + } +} + +fn decode_name(w: &[u8]) -> String { + let mut out = vec![]; + let mut i = 0; + while i < w.len() { + if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) { + out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2])); + i += 3; + } else { + out.push(w[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn parse_num(w: &[u8]) -> Option { + if w.is_empty() || !w.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !w.iter().any(u8::is_ascii_digit) { + return None; + } + let s = std::str::from_utf8(w).ok()?; + // "--5" and similar malformed numbers: take what parses. + s.parse::().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::().ok().map(|v| if s.starts_with('-') { -v } else { v })) +} + +// ------------------------------------------------------------------ filters + +/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, +/// JBIG2) give `None`. +pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { + let filters: Vec = match d.get("Filter").map(|f| file.resolve(f)) { + None => vec![], + Some(Obj::Name(n)) => vec![n.clone()], + Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(), + _ => vec![], + }; + let parms: Vec> = match d.get("DecodeParms").or_else(|| d.get("DP")).map(|p| file.resolve(p)) { + Some(Obj::Dict(p)) => vec![Some(p.clone())], + Some(Obj::Array(a)) => a.iter().map(|p| file.resolve(p).dict().cloned()).collect(), + _ => vec![], + }; + let mut data = raw.to_vec(); + for (i, f) in filters.iter().enumerate() { + data = match f.as_str() { + "FlateDecode" | "Fl" => inflate(&data)?, + "ASCIIHexDecode" | "AHx" => ascii_hex(&data), + "ASCII85Decode" | "A85" => ascii85(&data), + "LZWDecode" | "LZW" => { + lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0) + } + "RunLengthDecode" | "RL" => run_length(&data), + _ => return None, + }; + if let Some(Some(p)) = parms.get(i) + && matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW") + { + data = predict(&data, p); + } + } + Some(data) +} + +pub fn inflate(data: &[u8]) -> Option> { + miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { + // Truncated streams: keep what inflates. + let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); + let mut out = vec![0u8; data.len() * 8 + 1024]; + let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); + (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) + }) +} + +fn ascii_hex(data: &[u8]) -> Vec { + let mut hex: Vec = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect(); + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() +} + +pub fn ascii85(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut group = [0u32; 5]; + let mut n = 0; + let mut i = 0; + if data.starts_with(b"<~") { + i = 2; + } + while i < data.len() { + let c = data[i]; + i += 1; + match c { + b'~' => break, + b'z' if n == 0 => out.extend_from_slice(&[0; 4]), + b'!'..=b'u' => { + group[n] = (c - b'!') as u32; + n += 1; + if n == 5 { + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()); + n = 0; + } + } + _ => {} + } + } + if n > 1 { + for g in group.iter_mut().skip(n) { + *g = 84; + } + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()[..n - 1]); + } + out +} + +fn lzw(data: &[u8], early: bool) -> Vec { + let mut out = vec![]; + let mut table: Vec> = (0..=255u16).map(|b| vec![b as u8]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut bits, mut acc, mut nbits) = (9u32, 0u32, 0u32); + let mut prev: Option> = None; + for &b in data { + acc = (acc << 8) | b as u32; + nbits += 8; + while nbits >= bits { + let code = ((acc >> (nbits - bits)) & ((1 << bits) - 1)) as usize; + nbits -= bits; + match code { + 256 => { + table.truncate(258); + bits = 9; + prev = None; + continue; + } + 257 => return out, + _ => {} + } + let entry = if code < table.len() { + table[code].clone() + } else if let Some(p) = &prev { + let mut e = p.clone(); + e.push(p[0]); + e + } else { + return out; + }; + out.extend_from_slice(&entry); + if let Some(p) = prev { + let mut e = p; + e.push(entry[0]); + table.push(e); + } + prev = Some(entry); + let limit = table.len() + usize::from(early); + if limit >= (1 << bits) && bits < 12 { + bits += 1; + } + } + } + out +} + +fn run_length(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut i = 0; + while i < data.len() { + let l = data[i] as usize; + i += 1; + if l < 128 { + out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]); + i += l + 1; + } else if l > 128 { + if let Some(&b) = data.get(i) { + out.extend(std::iter::repeat_n(b, 257 - l)); + } + i += 1; + } else { + break; + } + } + out +} + +/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`). +fn predict(data: &[u8], p: &Dict) -> Vec { + let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize; + let pred = g("Predictor", 1.0); + if pred < 10 { + return data.to_vec(); + } + let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1); + let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8); + let mut out = Vec::with_capacity(data.len()); + let mut prev = vec![0u8; row]; + for chunk in data.chunks(row + 1) { + if chunk.len() < row + 1 { + break; + } + let ft = chunk[0]; + let mut cur = chunk[1..].to_vec(); + for i in 0..row { + let a = if i >= bpp { cur[i - bpp] as i32 } else { 0 }; + let b = prev[i] as i32; + let c = if i >= bpp { prev[i - bpp] as i32 } else { 0 }; + let add = match ft { + 1 => a, + 2 => b, + 3 => (a + b) / 2, + 4 => { + let pp = a + b - c; + let (pa, pb, pc) = ((pp - a).abs(), (pp - b).abs(), (pp - c).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + } + _ => 0, + }; + cur[i] = (cur[i] as i32 + add) as u8; + } + out.extend_from_slice(&cur); + prev = cur; + } + out +} + +// ------------------------------------------------------------------ the file + +/// Every indirect object of a PDF file, plus the trailer dictionaries found. +pub struct File { + pub objects: HashMap, + pub trailers: Vec, +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() || needle.is_empty() { + return None; + } + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +impl File { + pub fn parse(data: &[u8]) -> File { + let mut f = File { objects: HashMap::new(), trailers: vec![] }; + let mut i = 0; + while let Some(p) = find(data, b"obj", i) { + i = p + 3; + if p >= 3 && &data[p - 3..p] == b"end" { + continue; + } + if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) { + continue; + } + // Walk back over "num gen ". + let mut q = p; + let back_ws = |q: &mut usize| { + let s = *q; + while *q > 0 && is_white(data[*q - 1]) { + *q -= 1; + } + *q < s + }; + let back_digits = |q: &mut usize| { + let e = *q; + while *q > 0 && data[*q - 1].is_ascii_digit() { + *q -= 1; + } + (*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::().ok()).flatten() + }; + if !back_ws(&mut q) { + continue; + } + let Some(_gen) = back_digits(&mut q) else { continue }; + if !back_ws(&mut q) { + continue; + } + let Some(num) = back_digits(&mut q) else { continue }; + if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) { + continue; + } + let mut lx = Lexer::new(data, p + 3); + let Some(obj) = lx.next() else { continue }; + let obj = lx.maybe_ref(obj); + let obj = match obj { + Obj::Dict(d) => { + let save = lx.pos; + lx.skip_ws(); + if data[lx.pos..].starts_with(b"stream") { + let mut s = lx.pos + 6; + if data.get(s) == Some(&b'\r') { + s += 1; + } + if data.get(s) == Some(&b'\n') { + s += 1; + } + let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize); + let end = match declared.filter(|l| { + s + l <= data.len() && { + let mut k = s + l; + while k < data.len() && is_white(data[k]) { + k += 1; + } + data[k..].starts_with(b"endstream") + } + }) { + Some(l) => s + l, + None => { + let mut e = find(data, b"endstream", s).unwrap_or(data.len()); + if e > s && data[e - 1] == b'\n' { + e -= 1; + } + if e > s && data[e - 1] == b'\r' { + e -= 1; + } + e + } + }; + i = end; + Obj::Stream(d, data[s..end].to_vec()) + } else { + lx.pos = save; + Obj::Dict(d) + } + } + o => o, + }; + if let Obj::Stream(d, _) = &obj + && d.get("Type").and_then(Obj::name) == Some("XRef") + { + f.trailers.push(d.clone()); + } + f.objects.insert(num, obj); + } + // Trailer dictionaries of classic cross-reference sections. + let mut i = 0; + while let Some(p) = find(data, b"trailer", i) { + i = p + 7; + let mut lx = Lexer::new(data, i); + if let Some(Obj::Dict(d)) = lx.next() { + f.trailers.push(d); + } + } + // Object streams. + let streams: Vec<(Dict, Vec)> = f + .objects + .values() + .filter_map(|o| match o { + Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())), + _ => None, + }) + .collect(); + for (d, raw) in streams { + let Some(dec) = decode_stream(&f, &d, &raw) else { continue }; + let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; + let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; + let mut lx = Lexer::new(&dec, 0); + let mut heads = vec![]; + for _ in 0..n.min(100_000) { + let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break }; + heads.push((num as u32, off as usize)); + } + for (num, off) in heads { + if f.objects.contains_key(&num) || first + off >= dec.len() { + continue; + } + let mut lx = Lexer::new(&dec, first + off); + if let Some(o) = lx.next() { + let o = lx.maybe_ref(o); + f.objects.insert(num, o); + } + } + } + f + } + + /// Follow references (bounded). + pub fn resolve<'a>(&'a self, o: &'a Obj) -> &'a Obj { + let mut o = o; + for _ in 0..32 { + match o { + Obj::Ref(n, _) => match self.objects.get(n) { + Some(x) => o = x, + None => return &Obj::Null, + }, + _ => return o, + } + } + &Obj::Null + } + + pub fn get<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Obj> { + d.get(key).map(|o| self.resolve(o)).filter(|o| !matches!(o, Obj::Null)) + } + + pub fn get_dict<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Dict> { + self.get(d, key)?.dict() + } + + pub fn get_num(&self, d: &Dict, key: &str) -> Option { + self.get(d, key)?.num() + } + + /// Numbers of an array (references resolved). + pub fn nums(&self, o: &Obj) -> Vec { + self.resolve(o).array().map(|a| a.iter().filter_map(|x| self.resolve(x).num()).collect()).unwrap_or_default() + } + + /// The document catalog. + pub fn catalog(&self) -> Option<&Dict> { + for t in self.trailers.iter().rev() { + if let Some(Obj::Dict(d)) = t.get("Root").map(|r| self.resolve(r)) { + return Some(d); + } + } + let mut cats: Vec<(&u32, &Obj)> = + self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect(); + cats.sort_by_key(|(n, _)| **n); + cats.last().and_then(|(_, o)| o.dict()) + } + + pub fn encrypted(&self) -> bool { + self.trailers.iter().any(|t| t.contains_key("Encrypt")) + } +} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs new file mode 100644 index 000000000..9a67f85c4 --- /dev/null +++ b/crates/pdf/src/page.rs @@ -0,0 +1,573 @@ +//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state, +//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects +//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed +//! in [`effectcraft_svg::Doc::skipped`]). + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke}; +use kurbo::Point; + +use crate::build::Builder; +use crate::color::{Cs, Func, color_space, text_string}; +use crate::object::{Dict, File, Lexer, Obj, decode_stream}; + +/// A page: its dictionary with inherited attributes resolved. +pub struct Page { + pub dict: Dict, + pub resources: Dict, + /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. + pub bbox: [f64; 4], + pub rotate: i32, +} + +/// The pages in order. +pub fn pages(file: &File) -> Vec { + let mut out = vec![]; + let Some(cat) = file.catalog() else { return out }; + let Some(root) = file.get_dict(cat, "Pages") else { return out }; + fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { + if depth > 32 || out.len() > 10_000 { + return; + } + let res = file.get_dict(node, "Resources").cloned().or(inherited.0); + let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); + let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); + let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); + match file.get(node, "Kids").and_then(Obj::array) { + Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { + for k in kids { + if let Some(d) = file.resolve(k).dict() { + walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1); + } + } + } + _ => { + let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]); + let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])]; + out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) }); + } + } + } + walk(file, root, (None, None, None, 0), &mut out, 0); + out +} + +/// The page's content streams, decoded and joined. +pub fn page_content(file: &File, page: &Page) -> Vec { + let mut out = vec![]; + let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) { + Some(Obj::Array(a)) => a.iter().map(|x| file.resolve(x)).collect(), + Some(o) => vec![o], + None => vec![], + }; + for p in parts { + if let Obj::Stream(d, raw) = p + && let Some(data) = decode_stream(file, d, raw) + { + out.extend_from_slice(&data); + out.push(b'\n'); + } + } + out +} + +#[derive(Clone)] +enum Fill { + Color([f64; 3]), + Paint(Paint), + None, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + fill_cs: Cs, + stroke_cs: Cs, + fill: Fill, + stroke: Fill, + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + fill_alpha: f64, + stroke_alpha: f64, +} + +impl Default for GState { + fn default() -> Self { + GState { + ctm: Affine::IDENTITY, + fill_cs: Cs::Gray, + stroke_cs: Cs::Gray, + fill: Fill::Color([0.0; 3]), + stroke: Fill::Color([0.0; 3]), + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + fill_alpha: 1.0, + stroke_alpha: 1.0, + } + } +} + +pub(crate) struct Interp<'a> { + file: &'a File, + pub b: Builder, + gs: GState, + saved: Vec<(GState, usize)>, + path: BezPath, + cur: Option, + start: Option, + pending_clip: Option, + marked: Vec>, + /// The page box in default user space (for `sh`). + page_box: [f64; 4], + budget: usize, +} + +fn affine(v: &[f64]) -> Affine { + if v.len() < 6 { + return Affine::IDENTITY; + } + Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]) +} + +impl<'a> Interp<'a> { + pub fn new(file: &'a File, page_box: [f64; 4]) -> Self { + Interp { + file, + b: Builder::new(), + gs: GState::default(), + saved: vec![], + path: BezPath::new(), + cur: None, + start: None, + pending_clip: None, + marked: vec![], + page_box, + budget: 20_000_000, + } + } + + /// Run a content stream with resources `res`; `pattern_base` maps pattern space to the page + /// (the CTM where the stream's default coordinate system starts). + pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) { + let mut lx = Lexer::new(content, 0); + let mut args: Vec = Vec::with_capacity(8); + let mut in_text = false; + while let Some(o) = lx.next() { + let Obj::Op(op) = o else { + if args.len() < 64 { + args.push(o); + } + continue; + }; + if self.budget == 0 { + self.b.skip("content (operation limit)"); + return; + } + self.budget -= 1; + if in_text { + if op == "ET" { + in_text = false; + } + args.clear(); + continue; + } + let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0); + let nums = || args.iter().filter_map(Obj::num).collect::>(); + match op.as_str() { + "q" => self.saved.push((self.gs.clone(), self.b.depth())), + "Q" => { + if let Some((g, d)) = self.saved.pop() { + self.gs = g; + self.b.close_to(d); + } + } + "cm" => self.gs.ctm *= affine(&nums()), + "w" => self.gs.lw = n(0), + "J" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(n(0) as usize).min(2)], + "j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)], + "M" => self.gs.miter = n(0), + "d" => { + let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default(); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1))); + } + "gs" => { + if let Some(name) = args.first().and_then(Obj::name) + && let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name)) + { + self.ext_gstate(g); + } + } + // Path construction. + "m" => { + let p = Point::new(n(0), n(1)); + self.path.move_to(p); + self.cur = Some(p); + self.start = Some(p); + } + "l" => { + let p = Point::new(n(0), n(1)); + self.ensure_start(); + self.path.line_to(p); + self.cur = Some(p); + } + "c" => { + self.ensure_start(); + let p = Point::new(n(4), n(5)); + self.path.curve_to(Point::new(n(0), n(1)), Point::new(n(2), n(3)), p); + self.cur = Some(p); + } + "v" => { + self.ensure_start(); + let c0 = self.cur.unwrap_or_default(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(c0, Point::new(n(0), n(1)), p); + self.cur = Some(p); + } + "y" => { + self.ensure_start(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(Point::new(n(0), n(1)), p, p); + self.cur = Some(p); + } + "h" => { + if self.cur.is_some() { + self.path.close_path(); + self.cur = self.start; + } + } + "re" => { + let (x, y, w, h) = (n(0), n(1), n(2), n(3)); + self.path.move_to((x, y)); + self.path.line_to((x + w, y)); + self.path.line_to((x + w, y + h)); + self.path.line_to((x, y + h)); + self.path.close_path(); + self.cur = Some(Point::new(x, y)); + self.start = self.cur; + } + // Painting. + "S" => self.paint(false, None, true), + "s" => { + self.path.close_path(); + self.paint(false, None, true) + } + "f" | "F" => self.paint(true, Some(FillRule::NonZero), false), + "f*" => self.paint(true, Some(FillRule::EvenOdd), false), + "B" => self.paint(true, Some(FillRule::NonZero), true), + "B*" => self.paint(true, Some(FillRule::EvenOdd), true), + "b" => { + self.path.close_path(); + self.paint(true, Some(FillRule::NonZero), true) + } + "b*" => { + self.path.close_path(); + self.paint(true, Some(FillRule::EvenOdd), true) + } + "n" => self.paint(false, None, false), + "W" => self.pending_clip = Some(FillRule::NonZero), + "W*" => self.pending_clip = Some(FillRule::EvenOdd), + // Colour. + "g" => self.gs.fill_cs = Cs::Gray, + "G" => self.gs.stroke_cs = Cs::Gray, + "rg" => self.gs.fill_cs = Cs::Rgb, + "RG" => self.gs.stroke_cs = Cs::Rgb, + "k" => self.gs.fill_cs = Cs::Cmyk, + "K" => self.gs.stroke_cs = Cs::Cmyk, + "cs" | "CS" => { + let cs = args.first().map(|a| color_space(self.file, a, Some(res))).unwrap_or(Cs::Gray); + let init = Fill::Color(cs.to_rgb(&cs.initial())); + if op == "cs" { + self.gs.fill_cs = cs; + self.gs.fill = init; + } else { + self.gs.stroke_cs = cs; + self.gs.stroke = init; + } + } + "sc" | "scn" | "SC" | "SCN" => { + let fill = op.starts_with('s'); + let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() }; + let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) { + self.pattern(res, name, pattern_base) + } else { + Fill::Color(cs.to_rgb(&nums())) + }; + if fill { + self.gs.fill = v; + } else { + self.gs.stroke = v; + } + } + "sh" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.shade(res, name); + } + } + "Do" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.xobject(res, name, depth); + } + } + "BT" => { + in_text = true; + self.b.skip("text"); + } + "BI" => { + // Inline image: skip to EI. + let d = lx.data; + let mut p = lx.pos; + while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) { + p += 1; + } + while p + 2 < d.len() + && !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2]))) + { + p += 1; + } + lx.pos = (p + 2).min(d.len()); + self.b.skip("image"); + } + "BMC" => self.marked.push(None), + "BDC" => { + let layer = match (args.first().and_then(Obj::name), args.get(1)) { + (Some("OC"), Some(Obj::Name(prop))) => self + .file + .get_dict(res, "Properties") + .and_then(|p| self.file.get_dict(p, prop)) + .map(|ocg| self.ocg_name(ocg).unwrap_or_else(|| prop.clone())), + (Some("OC"), Some(Obj::Dict(ocg))) => self.ocg_name(ocg), + _ => None, + }; + match layer { + Some(name) => { + self.marked.push(Some(self.b.depth())); + self.b.open(&name, true); + } + None => self.marked.push(None), + } + } + "EMC" => { + if let Some(Some(d)) = self.marked.pop() { + self.b.close_to(d); + } + } + _ => {} + } + // Colour operands set by the device-space shorthands. + match op.as_str() { + "g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())), + "G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())), + _ => {} + } + args.clear(); + } + } + + fn ocg_name(&self, ocg: &Dict) -> Option { + // An optional-content membership dictionary names its groups in /OCGs. + let d = match self.file.get(ocg, "OCGs") { + Some(Obj::Dict(g)) => g, + Some(Obj::Array(a)) => a.first().map(|x| self.file.resolve(x)).and_then(Obj::dict).unwrap_or(ocg), + _ => ocg, + }; + match self.file.get(d, "Name") { + Some(Obj::Str(s)) => Some(text_string(s)), + _ => None, + } + } + + fn ensure_start(&mut self) { + if self.cur.is_none() { + self.path.move_to((0.0, 0.0)); + self.cur = Some(Point::ZERO); + self.start = self.cur; + } + } + + fn ext_gstate(&mut self, g: &Dict) { + let f = self.file; + if let Some(v) = f.get_num(g, "LW") { + self.gs.lw = v; + } + if let Some(v) = f.get_num(g, "LC") { + self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "LJ") { + self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "ML") { + self.gs.miter = v; + } + if let Some(v) = f.get_num(g, "CA") { + self.gs.stroke_alpha = v.clamp(0.0, 1.0); + } + if let Some(v) = f.get_num(g, "ca") { + self.gs.fill_alpha = v.clamp(0.0, 1.0); + } + if let Some(Obj::Array(d)) = f.get(g, "D") { + let arr = d.first().map(|a| f.nums(a)).unwrap_or_default(); + let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph)); + } + if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) { + self.b.skip("soft mask"); + } + } + + fn paint_of(f: &Fill) -> Option { + match f { + Fill::Color(c) => Some(Paint::Color(*c)), + Fill::Paint(p) => Some(p.clone()), + Fill::None => None, + } + } + + fn paint(&mut self, fill: bool, rule: Option, stroke: bool) { + let path = std::mem::take(&mut self.path); + self.cur = None; + self.start = None; + let ctm = self.gs.ctm; + let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None }; + let stroke = if stroke { + Self::paint_of(&self.gs.stroke).map(|p| { + // Width 0: the thinnest line the device can draw (one pixel). + let s = { (ctm.determinant().abs()).sqrt() }; + let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw }; + Stroke { + paint: p, + opacity: self.gs.stroke_alpha, + width, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.clone(), + } + }) + } else { + None + }; + if fill.is_some() || stroke.is_some() { + self.b.fill_stroke(path.clone(), ctm, fill, stroke); + } + if let Some(r) = self.pending_clip.take() { + self.b.clip(ctm * path, r); + } + } + + /// A shading dictionary → gradient paint in shading space. + fn shading_gradient(&mut self, sh: &Dict) -> Option { + let f = self.file; + let ty = f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32; + let cs = f.get(sh, "ColorSpace").map(|c| color_space(f, c, None)).unwrap_or(Cs::Rgb); + let coords = f.get(sh, "Coords").map(|c| f.nums(c)).unwrap_or_default(); + let func = f.get(sh, "Function").map(|x| Func::read(f, x)).unwrap_or(Func::Unsupported); + let dom = f.get(sh, "Domain").map(|d| f.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]); + let kind = match (ty, coords.len()) { + (2, 4) => GradientKind::Linear { x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3] }, + (3, 6) => GradientKind::Radial { cx: coords[3], cy: coords[4], r: coords[5], fx: coords[0], fy: coords[1] }, + _ => { + self.b.skip(&format!("shading type {ty}")); + return None; + } + }; + if func == Func::Unsupported { + self.b.skip("shading function"); + } + // Sample the function: evenly, plus exactly at stitching bounds. + let mut ts: Vec = (0..=24).map(|i| i as f64 / 24.0).collect(); + for b in func.breakpoints() { + if dom[1] > dom[0] { + ts.push(((b - dom[0]) / (dom[1] - dom[0])).clamp(0.0, 1.0)); + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let stops = ts + .into_iter() + .map(|t| { + let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0]))); + (t, [c[0], c[1], c[2], 1.0]) + }) + .collect(); + Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad }) + } + + fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill { + let f = self.file; + let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None }; + match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 { + 2 => { + let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None }; + let sh = sh.clone(); + match self.shading_gradient(&sh) { + // Gradient space → shape user space (shapes carry the CTM). + Some(mut g) => { + g.transform = self.gs.ctm.inverse() * base * m; + Fill::Paint(Paint::Gradient(g)) + } + None => Fill::None, + } + } + _ => { + self.b.skip("tiling pattern"); + Fill::Color([0.5; 3]) + } + } + } + + fn shade(&mut self, res: &Dict, name: &str) { + let f = self.file; + let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return }; + let Some(g) = self.shading_gradient(&sh) else { return }; + // Fill everything (the current clip limits it): the page box in user space. + let inv = self.gs.ctm.inverse(); + let b = self.page_box; + let mut path = BezPath::new(); + for (i, (x, y)) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].into_iter().enumerate() { + let p = inv * Point::new(x, y); + if i == 0 { + path.move_to(p); + } else { + path.line_to(p); + } + } + path.close_path(); + self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None); + } + + fn xobject(&mut self, res: &Dict, name: &str, depth: usize) { + let f = self.file; + let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return }; + let Obj::Stream(d, raw) = x else { return }; + match f.get(d, "Subtype").and_then(Obj::name) { + Some("Form") if depth < 12 => { + let Some(content) = decode_stream(f, d, raw) else { return }; + let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()); + self.saved.push((self.gs.clone(), self.b.depth())); + self.gs.ctm *= m; + let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default(); + if bb.len() == 4 { + let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]); + self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero); + } + let base = self.gs.ctm; + let path = std::mem::take(&mut self.path); + self.run(&content, &form_res, base, depth + 1); + self.path = path; + if let Some((g, dd)) = self.saved.pop() { + self.gs = g; + self.b.close_to(dd); + } + } + Some("Image") => self.b.skip("image"), + _ => {} + } + } +} diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs new file mode 100644 index 000000000..b9b0bff44 --- /dev/null +++ b/crates/pdf/src/ps.rs @@ -0,0 +1,1462 @@ +//! Encapsulated PostScript: a small PostScript interpreter (PostScript Language Reference, 3rd +//! edition, and the EPSF 3.0 specification) covering what exported vector EPS files use: the +//! operand and dictionary stacks, procedures and `def`, arithmetic, control flow, arrays and +//! dictionaries, the graphics state, path construction (including arcs) and painting, clipping +//! and device colours. Text is skipped, as are images (their inline data is stepped over) and +//! Level 3 smooth shading (`shfill`). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; +use kurbo::{PathEl, Point}; + +use crate::build::Builder; +use crate::object::{ascii85, is_white, parse_num}; + +type DictRef = Rc>>; + +#[derive(Clone)] +enum V { + Num(f64), + Bool(bool), + /// An executable name. + Name(Rc), + /// A literal name (`/name`). + Lit(Rc), + Str(Rc>>), + Proc(Rc>), + Array(Rc>>), + Dict(DictRef), + Mark, + Null, + /// `currentfile`. + File, +} + +impl V { + fn num(&self) -> Option { + match self { + V::Num(n) => Some(*n), + _ => None, + } + } + fn key(&self) -> String { + match self { + V::Name(n) | V::Lit(n) => n.to_string(), + V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(), + V::Num(n) => format!("{n}"), + V::Bool(b) => format!("{b}"), + _ => String::new(), + } + } + fn items(&self) -> Option> { + match self { + V::Array(a) => Some(a.borrow().clone()), + V::Proc(p) => Some(p.as_ref().clone()), + _ => None, + } + } +} + +enum Flow { + Exit, + Stop, + Quit, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + color: [f64; 3], + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + path: BezPath, + cur: Option, + start: Option, + depth: usize, +} + +/// Source tokenizer (the data source for `currentfile`). +struct Src<'a> { + d: &'a [u8], + pos: usize, +} + +impl Src<'_> { + fn skip_ws(&mut self) { + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + let s = self.pos; + while self.pos < self.d.len() && !matches!(self.d[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + // DSC: skip embedded binary / data sections. + let line = &self.d[s..self.pos]; + if line.starts_with(b"%%BeginData:") || line.starts_with(b"%%BeginBinary:") || line.starts_with(b"%%BeginPreview") { + let end: &[u8] = if line.starts_with(b"%%BeginData:") { + b"%%EndData" + } else if line.starts_with(b"%%BeginBinary:") { + b"%%EndBinary" + } else { + b"%%EndPreview" + }; + match self.d[self.pos..].windows(end.len()).position(|w| w == end) { + Some(p) => self.pos += p, + None => self.pos = self.d.len(), + } + } + } else { + break; + } + } + } + + fn word(&mut self) -> &[u8] { + let s = self.pos; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) || matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') { + break; + } + self.pos += 1; + } + &self.d[s..self.pos] + } + + /// The next token; `{ … }` comes back as a whole procedure. + fn token(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.d.get(self.pos)?; + Some(match c { + b'{' => { + self.pos += 1; + let mut body = vec![]; + loop { + self.skip_ws(); + match self.d.get(self.pos) { + None => break, + Some(b'}') => { + self.pos += 1; + break; + } + _ => { + if depth > 100 { + return None; + } + match self.token(depth + 1) { + Some(t) => body.push(t), + None => break, + } + } + } + } + V::Proc(Rc::new(body)) + } + b'}' => { + self.pos += 1; + V::Null + } + b'/' => { + self.pos += 1; + let imm = self.d.get(self.pos) == Some(&b'/'); + if imm { + self.pos += 1; + } + let w = String::from_utf8_lossy(self.word()).into_owned(); + // `//name` (immediately evaluated) is treated like a plain name. + if imm { V::Name(w.into()) } else { V::Lit(w.into()) } + } + b'(' => { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let e = self.d.get(self.pos).copied().unwrap_or(b'\\'); + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.d.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + b'\n' | b'\r' => {} + o => out.push(o), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + V::Str(Rc::new(RefCell::new(out))) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + V::Name("<<".into()) + } + b'>' if self.d.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + V::Name(">>".into()) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'~') => { + let s = self.pos; + while self.pos + 1 < self.d.len() && !(self.d[self.pos] == b'~' && self.d[self.pos + 1] == b'>') { + self.pos += 1; + } + self.pos = (self.pos + 2).min(self.d.len()); + V::Str(Rc::new(RefCell::new(ascii85(&self.d[s..self.pos])))) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.d.len() && self.d[self.pos] != b'>' { + if self.d[self.pos].is_ascii_hexdigit() { + hex.push(self.d[self.pos]); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + let h = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; + V::Str(Rc::new(RefCell::new(hex.chunks(2).map(|p| (h(p[0]) << 4) | h(p[1])).collect()))) + } + b'[' | b']' => { + self.pos += 1; + V::Name(if c == b'[' { "[" } else { "]" }.into()) + } + b')' | b'>' => { + self.pos += 1; + V::Null + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(V::Null); + } + match parse_num(w) { + Some(n) => V::Num(n), + None => { + // Radix numbers (16#FF). + let s = String::from_utf8_lossy(w).into_owned(); + match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) { + Some(n) => V::Num(n as f64), + None => V::Name(s.into()), + } + } + } + } + }) + } + + /// Step over inline image data that follows an `image` operator reading `currentfile`. + fn skip_image_data(&mut self) { + self.skip_ws(); + if self.d[self.pos..].starts_with(b"<~") || self.d.get(self.pos).is_some_and(|c| (b'!'..=b'u').contains(c) && !c.is_ascii_hexdigit()) { + match self.d[self.pos..].windows(2).position(|w| w == b"~>") { + Some(p) => self.pos += p + 2, + None => self.pos = self.d.len(), + } + return; + } + while self.pos < self.d.len() && (self.d[self.pos].is_ascii_hexdigit() || is_white(self.d[self.pos])) { + self.pos += 1; + } + if self.d.get(self.pos) == Some(&b'>') { + self.pos += 1; + } + } +} + +pub(crate) struct Interp { + pub b: Builder, + st: Vec, + dicts: Vec, + gs: GState, + saved: Vec, + budget: usize, + page: [f64; 4], + pub unknown: Vec, +} + +fn new_dict() -> DictRef { + Rc::new(RefCell::new(HashMap::new())) +} + +fn array(v: Vec) -> V { + V::Array(Rc::new(RefCell::new(v))) +} + +fn matrix_of(v: &V) -> Option { + let a: Vec = v.items()?.iter().filter_map(V::num).collect(); + (a.len() == 6).then(|| Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]])) +} + +fn matrix_val(m: Affine) -> V { + array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) +} + +impl Interp { + pub fn new(page: [f64; 4]) -> Interp { + Interp { + b: Builder::new(), + st: vec![], + dicts: vec![new_dict()], + gs: GState { + ctm: Affine::IDENTITY, + color: [0.0; 3], + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + path: BezPath::new(), + cur: None, + start: None, + depth: 1, + }, + saved: vec![], + budget: 8_000_000, + page, + unknown: vec![], + } + } + + pub fn run(&mut self, data: &[u8]) { + let mut src = Src { d: data, pos: 0 }; + while let Some(t) = src.token(0) { + if let Err(Flow::Quit) = self.exec_token(t, &mut src) { + break; + } + } + } + + fn pop(&mut self) -> V { + self.st.pop().unwrap_or(V::Null) + } + fn popn(&mut self) -> f64 { + self.pop().num().unwrap_or(0.0) + } + fn push(&mut self, v: V) { + if self.st.len() < 100_000 { + self.st.push(v); + } + } + + fn lookup(&self, k: &str) -> Option { + self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) + } + + /// A token read from the file: procedures are pushed, names executed. + fn exec_token(&mut self, t: V, src: &mut Src) -> Result<(), Flow> { + match t { + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + /// Execute a value as an object (procedures run). + fn exec_obj(&mut self, v: V, src: &mut Src) -> Result<(), Flow> { + match v { + V::Proc(p) => { + for x in p.iter() { + match x { + V::Name(n) => self.exec_name(n, src)?, + other => self.push(other.clone()), + } + } + Ok(()) + } + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + if self.budget == 0 { + self.b.skip("PostScript (operation limit)"); + return Err(Flow::Quit); + } + self.budget -= 1; + if let Some(v) = self.lookup(n) { + return match v { + V::Proc(_) => self.exec_obj(v, src), + other => { + self.push(other); + Ok(()) + } + }; + } + self.builtin(n, src) + } + + fn device_scale(&self) -> f64 { + self.gs.ctm.determinant().abs().sqrt() + } + + fn user_point(&mut self) -> Point { + let y = self.popn(); + let x = self.popn(); + Point::new(x, y) + } + + fn move_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + self.gs.path.move_to(q); + self.gs.cur = Some(q); + self.gs.start = Some(q); + } + fn line_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + if self.gs.cur.is_none() { + self.gs.path.move_to(q); + self.gs.start = Some(q); + } + self.gs.path.line_to(q); + self.gs.cur = Some(q); + } + fn curve_to(&mut self, a: Point, b: Point, c: Point) { + let m = self.gs.ctm; + if self.gs.cur.is_none() { + self.gs.path.move_to(m * a); + } + self.gs.path.curve_to(m * a, m * b, m * c); + self.gs.cur = Some(m * c); + } + fn current_user(&self) -> Point { + self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() + } + + fn arc(&mut self, neg: bool) { + let a2 = self.popn().to_radians(); + let a1 = self.popn().to_radians(); + let r = self.popn(); + let c = self.user_point(); + let mut sweep = a2 - a1; + if neg { + while sweep > 0.0 { + sweep -= std::f64::consts::TAU; + } + } else { + while sweep < 0.0 { + sweep += std::f64::consts::TAU; + } + } + let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); + if self.gs.cur.is_some() { + self.line_to(start); + } else { + self.move_to(start); + } + let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); + let m = self.gs.ctm; + arc.to_cubic_beziers(0.1, |p1, p2, p3| { + self.gs.path.curve_to(m * p1, m * p2, m * p3); + self.gs.cur = Some(m * p3); + }); + } + + fn paint(&mut self, fill: Option, stroke: bool) { + let path = std::mem::take(&mut self.gs.path); + self.gs.cur = None; + self.gs.start = None; + let s = self.device_scale(); + let st = stroke.then(|| Stroke { + paint: Paint::Color(self.gs.color), + opacity: 1.0, + width: if self.gs.lw <= 0.0 { 1.0 } else { self.gs.lw * s }, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.as_ref().map(|(a, o)| (a.iter().map(|v| v * s).collect(), o * s)), + }); + self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); + } + + fn rect_path(&mut self) -> Vec { + // x y w h, or an array / string of numbers. + let top = self.pop(); + let nums: Vec = match &top { + V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Num(h) => { + let w = self.popn(); + let y = self.popn(); + let x = self.popn(); + vec![x, y, w, *h] + } + _ => vec![], + }; + let m = self.gs.ctm; + nums.chunks_exact(4) + .map(|r| { + let mut p = BezPath::new(); + p.move_to(m * Point::new(r[0], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1] + r[3])); + p.line_to(m * Point::new(r[0], r[1] + r[3])); + p.close_path(); + p + }) + .collect() + } + + fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + let d = g.depth; + self.gs = g; + self.b.close_to(d); + } + } + + fn builtin(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + match n { + // Stack. + "pop" => { + self.pop(); + } + "exch" => { + let b = self.pop(); + let a = self.pop(); + self.push(b); + self.push(a); + } + "dup" => { + let a = self.st.last().cloned().unwrap_or(V::Null); + self.push(a); + } + "copy" => match self.pop() { + V::Num(k) => { + let k = (k.max(0.0) as usize).min(self.st.len()); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } + V::Array(dst) => { + let s = self.pop().items().unwrap_or_default(); + let mut d = dst.borrow_mut(); + for (i, v) in s.into_iter().enumerate() { + if i < d.len() { + d[i] = v; + } + } + drop(d); + self.push(V::Array(dst)); + } + other => self.push(other), + }, + "index" => { + let k = self.popn().max(0.0) as usize; + let v = if k < self.st.len() { self.st[self.st.len() - 1 - k].clone() } else { V::Null }; + self.push(v); + } + "roll" => { + let j = self.popn() as i64; + let k = (self.popn().max(0.0) as usize).min(self.st.len()); + if k > 0 { + let s = self.st.len() - k; + let r = j.rem_euclid(k as i64) as usize; + self.st[s..].rotate_right(r); + } + } + "clear" => self.st.clear(), + "count" => self.push(V::Num(self.st.len() as f64)), + "mark" | "[" | "<<" => self.push(V::Mark), + "cleartomark" => { + while let Some(v) = self.st.pop() { + if matches!(v, V::Mark) { + break; + } + } + } + "counttomark" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + self.push(V::Num(k as f64)); + } + "]" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + self.push(array(items)); + } + ">>" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + let d = new_dict(); + for kv in items.chunks_exact(2) { + d.borrow_mut().insert(kv[0].key(), kv[1].clone()); + } + self.push(V::Dict(d)); + } + // Arithmetic. + "add" | "sub" | "mul" | "div" | "idiv" | "mod" | "atan" | "exp" | "max" | "min" => { + let b = self.popn(); + let a = self.popn(); + let v = match n { + "add" => a + b, + "sub" => a - b, + "mul" => a * b, + "div" => { + if b == 0.0 { + 0.0 + } else { + a / b + } + } + "idiv" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) / (b as i64)) as f64 + } + } + "mod" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) % (b as i64)) as f64 + } + } + "atan" => a.atan2(b).to_degrees().rem_euclid(360.0), + "exp" => a.powf(b), + "max" => a.max(b), + _ => a.min(b), + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + "neg" | "abs" | "sqrt" | "sin" | "cos" | "ln" | "log" | "cvi" | "cvr" | "round" | "floor" | "ceiling" | "truncate" => { + let a = match self.pop() { + V::Str(s) => parse_num(&s.borrow()).unwrap_or(0.0), + v => v.num().unwrap_or(0.0), + }; + let v = match n { + "neg" => -a, + "abs" => a.abs(), + "sqrt" => a.max(0.0).sqrt(), + "sin" => a.to_radians().sin(), + "cos" => a.to_radians().cos(), + "ln" => a.ln(), + "log" => a.log10(), + "cvi" | "truncate" => a.trunc(), + "round" => (a + 0.5).floor(), + "floor" => a.floor(), + "ceiling" => a.ceil(), + _ => a, + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + // Comparison and logic. + "eq" | "ne" => { + let b = self.pop(); + let a = self.pop(); + let eq = match (&a, &b) { + (V::Num(x), V::Num(y)) => x == y, + (V::Bool(x), V::Bool(y)) => x == y, + (V::Null, V::Null) => true, + (V::Array(x), V::Array(y)) => Rc::ptr_eq(x, y), + (V::Dict(x), V::Dict(y)) => Rc::ptr_eq(x, y), + (x, y) => !x.key().is_empty() && x.key() == y.key(), + }; + self.push(V::Bool(eq == (n == "eq"))); + } + "gt" | "ge" | "lt" | "le" => { + let b = self.popn(); + let a = self.popn(); + self.push(V::Bool(match n { + "gt" => a > b, + "ge" => a >= b, + "lt" => a < b, + _ => a <= b, + })); + } + "not" => match self.pop() { + V::Bool(b) => self.push(V::Bool(!b)), + v => self.push(V::Num(!(v.num().unwrap_or(0.0) as i64) as f64)), + }, + "and" | "or" | "xor" => { + let b = self.pop(); + let a = self.pop(); + match (a, b) { + (V::Bool(x), V::Bool(y)) => self.push(V::Bool(match n { + "and" => x && y, + "or" => x || y, + _ => x ^ y, + })), + (x, y) => { + let (x, y) = (x.num().unwrap_or(0.0) as i64, y.num().unwrap_or(0.0) as i64); + self.push(V::Num(match n { + "and" => x & y, + "or" => x | y, + _ => x ^ y, + } as f64)); + } + } + } + "true" => self.push(V::Bool(true)), + "false" => self.push(V::Bool(false)), + "null" => self.push(V::Null), + // Control. + "exec" => { + let v = self.pop(); + self.exec_obj(v, src)?; + } + "if" => { + let p = self.pop(); + if let V::Bool(true) = self.pop() { + self.exec_obj(p, src)?; + } + } + "ifelse" => { + let f = self.pop(); + let t = self.pop(); + let c = matches!(self.pop(), V::Bool(true)); + self.exec_obj(if c { t } else { f }, src)?; + } + "repeat" => { + let p = self.pop(); + let k = self.popn().clamp(0.0, 1e6) as usize; + for _ in 0..k { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "for" => { + let p = self.pop(); + let lim = self.popn(); + let inc = self.popn(); + let mut i = self.popn(); + let mut guard = 0; + while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.push(V::Num(i)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + i += inc; + guard += 1; + } + } + "loop" => { + let p = self.pop(); + for _ in 0..1_000_000 { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "forall" => { + let p = self.pop(); + match self.pop() { + V::Dict(d) => { + let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in items { + self.push(V::Lit(k.into())); + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + V::Str(s) => { + let bytes = s.borrow().clone(); + for c in bytes { + self.push(V::Num(c as f64)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + a => { + for v in a.items().unwrap_or_default() { + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + } + } + "exit" => return Err(Flow::Exit), + "stop" => return Err(Flow::Stop), + "stopped" => { + let p = self.pop(); + let stopped = match self.exec_obj(p, src) { + Err(Flow::Quit) => return Err(Flow::Quit), + Err(_) => true, + Ok(()) => false, + }; + self.push(V::Bool(stopped)); + } + "quit" => return Err(Flow::Quit), + "showpage" | "copypage" => {} + // Dictionaries. + "def" => { + let v = self.pop(); + let k = self.pop().key(); + if let Some(d) = self.dicts.last() { + d.borrow_mut().insert(k, v); + } + } + "store" => { + let v = self.pop(); + let k = self.pop().key(); + let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned(); + if let Some(d) = d { + d.borrow_mut().insert(k, v); + } + } + "load" => { + let k = self.pop().key(); + let v = self.lookup(&k).unwrap_or(V::Name(k.into())); + self.push(v); + } + "undef" => { + let k = self.pop().key(); + if let V::Dict(d) = self.pop() { + d.borrow_mut().remove(&k); + } + } + "dict" => { + self.pop(); + self.push(V::Dict(new_dict())); + } + "begin" => { + if let V::Dict(d) = self.pop() + && self.dicts.len() < 64 + { + self.dicts.push(d); + } + } + "end" => { + if self.dicts.len() > 1 { + self.dicts.pop(); + } + } + "currentdict" => { + let d = self.dicts.last().cloned().unwrap_or_else(new_dict); + self.push(V::Dict(d)); + } + "userdict" | "globaldict" | "systemdict" | "statusdict" | "errordict" | "$error" => { + let d = self.dicts[0].clone(); + self.push(V::Dict(d)); + } + "where" => { + let k = self.pop().key(); + match self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).cloned() { + Some(d) => { + self.push(V::Dict(d)); + self.push(V::Bool(true)); + } + None => self.push(V::Bool(false)), + } + } + "known" => { + let k = self.pop().key(); + let v = match self.pop() { + V::Dict(d) => d.borrow().contains_key(&k), + _ => false, + }; + self.push(V::Bool(v)); + } + "countdictstack" => self.push(V::Num(self.dicts.len() as f64)), + "cleardictstack" => self.dicts.truncate(1), + "findresource" | "findfont" | "findencoding" => { + if n == "findresource" { + self.pop(); + } + self.pop(); + self.push(V::Dict(new_dict())); + } + "defineresource" => { + self.pop(); + let v = self.pop(); + self.pop(); + self.push(v); + } + "resourcestatus" => { + self.pop(); + self.pop(); + self.push(V::Bool(false)); + } + // Arrays and strings. + "array" => { + let k = self.popn().clamp(0.0, 1e6) as usize; + self.push(array(vec![V::Null; k])); + } + "string" => { + let k = self.popn().clamp(0.0, 1e7) as usize; + self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); + } + "length" => { + let l = match self.pop() { + V::Str(s) => s.borrow().len(), + V::Dict(d) => d.borrow().len(), + V::Name(s) | V::Lit(s) => s.len(), + v => v.items().map_or(0, |i| i.len()), + }; + self.push(V::Num(l as f64)); + } + "get" => { + let k = self.pop(); + let v = match self.pop() { + V::Dict(d) => d.borrow().get(&k.key()).cloned().unwrap_or(V::Null), + V::Str(s) => V::Num(s.borrow().get(k.num().unwrap_or(0.0) as usize).copied().unwrap_or(0) as f64), + a => a.items().and_then(|i| i.get(k.num().unwrap_or(0.0) as usize).cloned()).unwrap_or(V::Null), + }; + self.push(v); + } + "put" => { + let v = self.pop(); + let k = self.pop(); + match self.pop() { + V::Dict(d) => { + d.borrow_mut().insert(k.key(), v); + } + V::Array(a) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = a.borrow_mut().get_mut(i) { + *slot = v; + } + } + V::Str(s) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = s.borrow_mut().get_mut(i) { + *slot = v.num().unwrap_or(0.0) as u8; + } + } + _ => {} + } + } + "getinterval" => { + let c = self.popn().max(0.0) as usize; + let i = self.popn().max(0.0) as usize; + match self.pop() { + V::Str(s) => { + let b = s.borrow(); + let part = b.get(i.min(b.len())..(i + c).min(b.len())).unwrap_or(&[]).to_vec(); + drop(b); + self.push(V::Str(Rc::new(RefCell::new(part)))); + } + a => { + let items = a.items().unwrap_or_default(); + let part = items.get(i.min(items.len())..(i + c).min(items.len())).unwrap_or(&[]).to_vec(); + self.push(array(part)); + } + } + } + "putinterval" => { + self.pop(); + self.pop(); + self.pop(); + } + "aload" => { + let a = self.pop(); + for v in a.items().unwrap_or_default() { + self.push(v); + } + self.push(a); + } + "astore" => { + let a = self.pop(); + if let V::Array(arr) = &a { + let k = arr.borrow().len().min(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + *arr.borrow_mut() = items; + } + self.push(a); + } + "cvx" => match self.pop() { + V::Array(a) => self.push(V::Proc(Rc::new(a.borrow().clone()))), + V::Lit(s) => self.push(V::Name(s)), + V::Str(s) => { + // Executable string: tokenize it as a procedure. + let bytes = s.borrow().clone(); + let mut sub = Src { d: &bytes, pos: 0 }; + let mut body = vec![]; + while let Some(t) = sub.token(0) { + body.push(t); + } + self.push(V::Proc(Rc::new(body))); + } + v => self.push(v), + }, + "cvlit" => match self.pop() { + V::Proc(p) => self.push(array(p.as_ref().clone())), + V::Name(s) => self.push(V::Lit(s)), + v => self.push(v), + }, + "cvn" => { + let k = self.pop().key(); + self.push(V::Lit(k.into())); + } + "cvs" => { + self.pop(); + let k = self.pop().key(); + self.push(V::Str(Rc::new(RefCell::new(k.into_bytes())))); + } + "xcheck" => { + let v = self.pop(); + self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_)))); + } + "type" => { + let t = match self.pop() { + V::Num(_) => "realtype", + V::Bool(_) => "booleantype", + V::Name(_) | V::Lit(_) => "nametype", + V::Str(_) => "stringtype", + V::Proc(_) | V::Array(_) => "arraytype", + V::Dict(_) => "dicttype", + V::Mark => "marktype", + V::Null => "nulltype", + V::File => "filetype", + }; + self.push(V::Name(t.into())); + } + "bind" | "readonly" | "executeonly" | "noaccess" => {} + "languagelevel" => self.push(V::Num(3.0)), + "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), + "save" => { + self.saved.push(self.gs.clone()); + self.gs.depth = self.b.depth(); + self.push(V::Null); + } + "restore" => { + self.pop(); + self.grestore(); + } + "currentfile" => self.push(V::File), + "readstring" | "readhexstring" | "readline" => { + self.pop(); + self.pop(); + self.push(V::Str(Rc::new(RefCell::new(vec![])))); + self.push(V::Bool(false)); + } + "flushfile" | "closefile" | "print" | "==" | "=" | "pstack" | "setpagedevice" | "setglobal" | "setpacking" | "setshared" | "setobjectformat" + | "setsmoothness" => { + if !matches!(n, "pstack") { + self.pop(); + } + } + "currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)), + "vmstatus" => { + for _ in 0..3 { + self.push(V::Num(0.0)); + } + } + // Graphics state. + "gsave" => { + let mut g = self.gs.clone(); + g.depth = self.b.depth(); + self.saved.push(g); + } + "grestore" => self.grestore(), + "grestoreall" => { + while !self.saved.is_empty() { + self.grestore(); + } + } + "initgraphics" => { + self.gs.ctm = Affine::IDENTITY; + self.gs.lw = 1.0; + self.gs.color = [0.0; 3]; + } + "setlinewidth" => self.gs.lw = self.popn(), + "currentlinewidth" => self.push(V::Num(self.gs.lw)), + "setlinecap" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(self.popn().max(0.0) as usize).min(2)], + "setlinejoin" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(self.popn().max(0.0) as usize).min(2)], + "setmiterlimit" => self.gs.miter = self.popn(), + "setdash" => { + let off = self.popn(); + let a: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); + self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off)); + } + "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => { + self.pop(); + } + "setgray" => { + let g = self.popn().clamp(0.0, 1.0); + self.gs.color = [g; 3]; + } + "setrgbcolor" => { + let b = self.popn(); + let g = self.popn(); + let r = self.popn(); + self.gs.color = [r, g, b].map(|v| v.clamp(0.0, 1.0)); + } + "sethsbcolor" => { + let v = self.popn().clamp(0.0, 1.0); + let s = self.popn().clamp(0.0, 1.0); + let h = self.popn().rem_euclid(1.0) * 6.0; + let (i, f) = (h.floor() as i32, h.fract()); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + self.gs.color = match i { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + }; + } + "setcmykcolor" => { + let k = self.popn().clamp(0.0, 1.0); + let y = self.popn().clamp(0.0, 1.0); + let m = self.popn().clamp(0.0, 1.0); + let c = self.popn().clamp(0.0, 1.0); + self.gs.color = [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + } + "setcolorspace" => { + self.pop(); + } + "setcolor" => { + // Device colour by operand count (patterns are not drawn). + let k = self.st.iter().rev().take_while(|v| matches!(v, V::Num(_))).count().min(4); + let c: Vec = self.st.split_off(self.st.len() - k).iter().filter_map(V::num).collect(); + self.gs.color = match c.len() { + 1 => [c[0]; 3], + 3 => [c[0], c[1], c[2]], + 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], + _ => self.gs.color, + }; + } + "currentgray" => self.push(V::Num((self.gs.color[0] + self.gs.color[1] + self.gs.color[2]) / 3.0)), + "currentrgbcolor" => { + for c in self.gs.color { + self.push(V::Num(c)); + } + } + // Coordinates. + "matrix" => self.push(matrix_val(Affine::IDENTITY)), + "identmatrix" | "defaultmatrix" => { + self.pop(); + self.push(matrix_val(Affine::IDENTITY)); + } + "currentmatrix" => { + self.pop(); + self.push(matrix_val(self.gs.ctm)); + } + "setmatrix" => { + let m = self.pop(); + self.gs.ctm = matrix_of(&m).unwrap_or(Affine::IDENTITY); + } + "concat" => { + let m = self.pop(); + if let Some(m) = matrix_of(&m) { + self.gs.ctm *= m; + } + } + "concatmatrix" => { + self.pop(); + let b = self.pop(); + let a = self.pop(); + let (a, b) = (matrix_of(&a).unwrap_or(Affine::IDENTITY), matrix_of(&b).unwrap_or(Affine::IDENTITY)); + self.push(matrix_val(b * a)); + } + "invertmatrix" => { + self.pop(); + let a = self.pop(); + self.push(matrix_val(matrix_of(&a).unwrap_or(Affine::IDENTITY).inverse())); + } + "translate" | "scale" => { + let top = self.pop(); + let (m_out, y) = match top { + V::Array(_) => (Some(top), self.popn()), + v => (None, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let t = if n == "translate" { Affine::translate((x, y)) } else { Affine::scale_non_uniform(x, y) }; + match m_out { + Some(_) => self.push(matrix_val(t)), + None => self.gs.ctm *= t, + } + } + "rotate" => { + let top = self.pop(); + match top { + V::Array(_) => { + let a = self.popn(); + self.push(matrix_val(Affine::rotate(a.to_radians()))); + } + v => self.gs.ctm *= Affine::rotate(v.num().unwrap_or(0.0).to_radians()), + } + } + "transform" | "itransform" | "dtransform" | "idtransform" => { + let top = self.pop(); + let (m, y) = match top { + V::Array(_) | V::Proc(_) => (matrix_of(&top).unwrap_or(Affine::IDENTITY), self.popn()), + v => (self.gs.ctm, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let m = if n.starts_with('i') { m.inverse() } else { m }; + let p = if n.contains('d') { + let c = m.as_coeffs(); + Point::new(c[0] * x + c[2] * y, c[1] * x + c[3] * y) + } else { + m * Point::new(x, y) + }; + self.push(V::Num(p.x)); + self.push(V::Num(p.y)); + } + // Paths. + "newpath" => { + self.gs.path = BezPath::new(); + self.gs.cur = None; + self.gs.start = None; + } + "moveto" => { + let p = self.user_point(); + self.move_to(p); + } + "rmoveto" => { + let d = self.user_point(); + let c = self.current_user(); + self.move_to(Point::new(c.x + d.x, c.y + d.y)); + } + "lineto" => { + let p = self.user_point(); + self.line_to(p); + } + "rlineto" => { + let d = self.user_point(); + let c = self.current_user(); + self.line_to(Point::new(c.x + d.x, c.y + d.y)); + } + "curveto" | "rcurveto" => { + let p3 = self.user_point(); + let p2 = self.user_point(); + let p1 = self.user_point(); + if n == "rcurveto" { + let c = self.current_user(); + let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); + self.curve_to(o(p1), o(p2), o(p3)); + } else { + self.curve_to(p1, p2, p3); + } + } + "closepath" => { + if self.gs.cur.is_some() { + self.gs.path.close_path(); + self.gs.cur = self.gs.start; + } + } + "arc" => self.arc(false), + "arcn" => self.arc(true), + "currentpoint" => { + let c = self.current_user(); + self.push(V::Num(c.x)); + self.push(V::Num(c.y)); + } + "pathbbox" => { + let r = kurbo::Shape::bounding_box(&self.gs.path); + let inv = self.gs.ctm.inverse(); + let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); + for v in [a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)] { + self.push(V::Num(v)); + } + } + "flattenpath" | "reversepath" | "strokepath" | "initclip" => {} + "clippath" => { + let p = self.page; + self.gs.path = kurbo::Shape::to_path(&kurbo::Rect::new(p[0], p[1], p[2], p[3]), 0.1); + } + // Painting. + "fill" => self.paint(Some(FillRule::NonZero), false), + "eofill" => self.paint(Some(FillRule::EvenOdd), false), + "stroke" => self.paint(None, true), + "clip" | "eoclip" => { + let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + let p = self.gs.path.clone(); + if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { + self.b.clip(p, rule); + } + } + "rectfill" | "rectstroke" | "rectclip" => { + let paths = self.rect_path(); + if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { + self.pop(); + } + let mut all = BezPath::new(); + for p in paths { + all.extend(p); + } + match n { + "rectclip" => self.b.clip(all, FillRule::NonZero), + _ => { + let saved = std::mem::replace(&mut self.gs.path, all); + self.paint(if n == "rectfill" { Some(FillRule::NonZero) } else { None }, n == "rectstroke"); + self.gs.path = saved; + } + } + } + "shfill" => { + self.pop(); + self.b.skip("smooth shading (shfill)"); + } + // Text (skipped). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "setcachedevice" | "setcharwidth" => { + match n { + "scalefont" | "makefont" => { + self.pop(); + } + "selectfont" => { + self.pop(); + self.pop(); + } + "charpath" => { + self.pop(); + self.pop(); + } + "setcachedevice" => { + for _ in 0..6 { + self.pop(); + } + } + "setcharwidth" => { + self.pop(); + self.pop(); + } + "rootfont" => self.push(V::Dict(new_dict())), + _ => { + self.pop(); + } + } + if matches!(n, "show" | "charpath" | "glyphshow") { + self.b.skip("text"); + } + } + "currentfont" => self.push(V::Dict(new_dict())), + "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => { + let k = match n { + "ashow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => 3, + "widthshow" => 4, + _ => 6, + }; + for _ in 0..k.min(self.st.len()) { + self.pop(); + } + self.b.skip("text"); + } + "stringwidth" => { + self.pop(); + self.push(V::Num(0.0)); + self.push(V::Num(0.0)); + } + // Images: operands popped, inline data skipped. + "image" | "imagemask" | "colorimage" => { + let mut reads_file = false; + let check = |v: &V, rf: &mut bool| { + if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { + *rf = true; + } + }; + if n == "colorimage" { + let ncomp = self.popn().max(1.0) as usize; + let multi = matches!(self.pop(), V::Bool(true)); + for _ in 0..if multi { ncomp.min(4) } else { 1 } { + let v = self.pop(); + check(&v, &mut reads_file); + } + for _ in 0..4 { + self.pop(); + } + } else { + match self.pop() { + V::Dict(d) => { + if let Some(ds) = d.borrow().get("DataSource") { + check(ds, &mut reads_file); + } + } + top => { + check(&top, &mut reads_file); + for _ in 0..4 { + self.pop(); + } + } + } + } + if reads_file { + src.skip_image_data(); + } + self.b.skip("image"); + } + _ => { + if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { + self.unknown.push(n.to_string()); + } + } + } + Ok(()) + } +} + +/// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box +/// (`%%HiResBoundingBox` preferred). +pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { + let ps = if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && bytes.len() >= 12 { + let off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; + bytes.get(off..off.checked_add(len)?.min(bytes.len()))? + } else { + bytes + }; + let find_box = |key: &[u8]| -> Option<[f64; 4]> { + let mut from = 0; + while let Some(p) = ps[from..].windows(key.len()).position(|w| w == key) { + let s = from + p + key.len(); + let e = ps[s..].iter().position(|c| matches!(c, b'\n' | b'\r')).map_or(ps.len(), |q| s + q); + let v: Vec = String::from_utf8_lossy(&ps[s..e]).split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { + return Some([v[0], v[1], v[2], v[3]]); + } + from = s; + } + None + }; + let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; + Some((ps, bbox)) +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs new file mode 100644 index 000000000..b4e91f2bd --- /dev/null +++ b/crates/pdf/src/tests.rs @@ -0,0 +1,150 @@ +//! Test files are generated here (no third-party samples). + +use super::*; + +use crate::write::pdf; + +const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\ +/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\ +/Pattern cs /P0 scn 100 0 100 50 re f\n\ +0 0 1 RG 4 w 120 80 m 180 80 l S\n\ +BT /F1 12 Tf (ignored) Tj ET\nEMC\n"; + +pub(crate) fn sample_pdf(object_stream: bool) -> Vec { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>"; + let mut objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None), + (3, page.to_string(), None), + (4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())), + (7, pattern.to_string(), None), + ]; + let ocg5 = "<< /Type /OCG /Name (Background) >>"; + let ocg6 = "<< /Type /OCG /Name >>"; // "Art" in UTF-16BE + if object_stream { + let body = format!("{ocg5} {ocg6}"); + let head = format!("5 0 6 {} ", ocg5.len() + 1); + let data = format!("{head}{body}"); + objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes()))); + } else { + objs.push((5, ocg5.to_string(), None)); + objs.push((6, ocg6.to_string(), None)); + } + pdf(&objs, 1) +} + +fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] { + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(doc, w, h, 1.0); + img.get(x, y) +} + +#[test] +fn pdf_paths_clip_gradient_and_layers() { + for objstm in [false, true] { + let bytes = sample_pdf(objstm); + assert_eq!(sniff(&bytes), Some(Format::Pdf)); + assert_eq!(page_count(&bytes), 1); + let doc = parse(&bytes).unwrap(); + assert_eq!((doc.width, doc.height), (200.0, 100.0)); + assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); + assert!(doc.skipped.contains(&"text".to_string())); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let at = |x: i64, y: i64| img.get(x, y); + // Red rectangle clipped to x ≥ 10, over the green background. + assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50)); + assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50)); + assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50)); + // Axial shading, red → blue left to right (in the lower half: PDF y is up). + let (l, r) = (at(110, 75), at(190, 75)); + assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}"); + assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25)); + // Stroked line (PDF y = 80 → 20 px from the top). + assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20)); + // One layer at a time. + let bg = layer_doc(&doc, 0); + assert!(px(&bg, 30, 50)[1] > 0.99); + let art = layer_doc(&doc, 1); + assert_eq!(px(&art, 5, 50)[3], 0.0); + // Continuous rasterisation: twice the size stays sharp at the clip edge. + let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0); + assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99); + } +} + +#[test] +fn pdf_forms_rotation_cmyk_and_alpha() { + let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n"; + let form = "0 1 1 0 k 0 0 20 20 re f"; + let objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None), + ( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>" + .to_string(), + None, + ), + (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())), + ]; + let doc = parse(&pdf(&objs, 1)).unwrap(); + // Rotated a quarter turn: 50 × 100. + assert_eq!((doc.width, doc.height), (50.0, 100.0)); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + // The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the + // half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x). + let red = img.get(15, 15); + assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}"); + let dark = img.get(5, 5); + assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}"); + assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box"); +} + +const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ +/m {moveto} bind def /l {lineto} bind def\n\ +/cm { 6 array astore concat } bind def\n\ +/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\ +1 0 0 setrgbcolor\n10 10 30 30 box fill\n\ +gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\ +gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\ +0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\ +/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\ +showpage\n%%EOF\n"; + +#[test] +fn eps_postscript_subset() { + let doc = parse(EPS.as_bytes()).unwrap(); + assert_eq!((doc.width, doc.height), (100.0, 100.0)); + assert!(doc.skipped.contains(&"text".to_string())); + let at = |x, y| px(&doc, x, y); + assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75)); + assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50)); + assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95)); + let line = at(50, 10); + assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}"); + assert_eq!(at(95, 70)[3], 0.0); + // DOS EPS binary header (with a fake TIFF preview after the PostScript). + let ps = EPS.as_bytes(); + let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; + dos.extend_from_slice(&30u32.to_le_bytes()); + dos.extend_from_slice(&(ps.len() as u32).to_le_bytes()); + dos.extend_from_slice(&[0; 18]); + dos.extend_from_slice(ps); + dos.extend_from_slice(b"II*\0 not a real preview"); + let d2 = parse(&dos).unwrap(); + assert_eq!(d2.root.children.len(), doc.root.children.len()); + assert_eq!(codec("art.eps", &dos), Some("EPS")); + assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI")); + assert_eq!(parse(b"hello"), Err(Error::NotVector)); +} + +#[test] +fn filters_decode() { + assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); + let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); + assert_eq!(object::inflate(&z).unwrap(), b"abc"); +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs new file mode 100644 index 000000000..9e54bdc37 --- /dev/null +++ b/crates/pdf/src/write.rs @@ -0,0 +1,32 @@ +//! A minimal PDF writer for generating test files (EffectCraft does not export PDF). + +/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a +/// cross-reference table and a trailer naming object `root` as the catalog. +pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = vec![]; + for (n, dict, stream) in objects { + offsets.push((*n, out.len())); + match stream { + Some(data) => { + let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6); + let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len()); + out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes()); + out.extend_from_slice(&z); + out.extend_from_slice(b"\nendstream\nendobj\n"); + } + None => out.extend_from_slice(format!("{n} 0 obj\n{dict}\nendobj\n").as_bytes()), + } + } + let xref = out.len(); + let max = objects.iter().map(|o| o.0).max().unwrap_or(0); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", max + 1).as_bytes()); + for i in 1..=max { + match offsets.iter().find(|o| o.0 == i) { + Some((_, off)) => out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()), + None => out.extend_from_slice(b"0000000000 65535 f \n"), + } + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n", max + 1).as_bytes()); + out +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 470c4edcd..5f90c291c 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -826,9 +826,9 @@ pub struct Proxy { pub enabled: bool, } -/// A layer of a layered still (a Photoshop document) used as footage -/// (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// A layer of a layered still (a Photoshop document, or a PDF / Illustrator / EPS file) used as +/// footage (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SourceLayer { /// Layer record index in the file (bottom of the stack = 0). pub index: u32, @@ -836,6 +836,10 @@ pub struct SourceLayer { /// The footage is the layer's own bounds (Retain Layer Sizes) rather than the document size. #[serde(default)] pub layer_size: bool, + /// A Photoshop smart object: the unique id of its embedded file (linked layer data), which + /// is the footage instead of the layer's pixels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub embedded: Option, } fn one() -> u32 { @@ -852,6 +856,7 @@ pub struct Solid { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum ItemKind { Folder, Comp(Arc), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fd9be466b..e2c91e60d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -66,7 +66,7 @@ pub trait FootageSource: Send + Sync { /// Footage that can be rasterised at any scale (SVG). pub fn is_vector_footage(f: &Footage) -> bool { - f.codec == "SVG" + matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") } /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 22b5feadd..0536480c9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -170,6 +170,9 @@ pub struct Group { pub transform: Affine, pub opacity: f64, pub children: Vec, + /// Clipping paths in the group's space (after `transform`); the children show only where + /// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet). + pub clip: Vec<(BezPath, FillRule)>, } #[derive(Clone, Debug, PartialEq)] @@ -666,7 +669,7 @@ impl<'a, 'i> Parser<'a, 'i> { for c in n.children() { self.node(c, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "svg" => { // Nested viewport. @@ -685,7 +688,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } (self.vw, self.vh) = saved; - out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] })); } "use" => { let Some(target) = self.href(n) else { return }; @@ -706,11 +709,11 @@ impl<'a, 'i> Parser<'a, 'i> { for c in target.children() { self.node(c, &sst, &mut inner, depth + 1); } - children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner })); + children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -750,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } if !children.is_empty() { - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } } } @@ -942,7 +945,7 @@ pub fn parse(bytes: &[u8]) -> Result { p.node(c, &rst, &mut children, 1); } let transform = viewbox_transform(root, dw, dh); - Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) }) + Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) }) } #[cfg(test)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 2cfb3dd3a..de82695b1 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -31,6 +31,27 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { if o <= 0.0 { return; } + if !g.clip.is_empty() { + // Draw the children on their own, keep what every clip path covers, composite. + let mut tmp = Image::new(dst.width, dst.height); + for c in &g.children { + draw_node(c, m, 1.0, &mut tmp); + } + for (path, rule) in &g.clip { + let rule = match rule { + FillRule::NonZero => effectcraft_path::FillRule::NonZero, + FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, + }; + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule); + tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| { + for v in p.iter_mut() { + *v *= c; + } + }); + } + over(dst, &tmp, o as f32); + return; + } if g.opacity < 1.0 && g.children.len() > 1 { let mut tmp = Image::new(dst.width, dst.height); for c in &g.children { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eed7e6391..acf5a5446 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -813,7 +813,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21928b90d..ad904eba6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,6 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ("effects", 2), ("track", 2), ("svg", 2), + ("pdf", 2), ("model", 2), ("render", 3), ("media", 3), @@ -58,6 +59,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("path", "keyframe"), ("path", "raster"), ("text", "path"), + ("pdf", "svg"), ("effects", "project"), ("effects", "text"), ("effects", "path"), From 7352b624d462bdb77f6f1f1c7a45e1377511b652 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:18:05 -0400 Subject: [PATCH 18/20] M13.2: PSD smart objects (SoLd/PlLd + lnk2 embedded files) import as placed footage of the embedded document or image Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/psd_import.rs | 60 ++++++++++- crates/host/tests/vector_import.rs | 37 +++++++ crates/media/src/layered.rs | 11 ++ crates/media/src/pool.rs | 5 +- crates/psd/README.md | 8 +- crates/psd/src/lib.rs | 163 ++++++++++++++++++++++++++++- crates/psd/src/tests.rs | 31 ++++++ crates/psd/src/write.rs | 57 +++++++++- 8 files changed, 365 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 2f012eee6..555dd97b2 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -6,7 +6,9 @@ //! so their blend modes act on the layers below, like Photoshop); type layers become editable //! text layers; layer masks are baked into the layer's alpha and vector masks become masks; //! layer effects map to Layer Styles; adjustment layers become adjustment layers with the -//! matching effect (best effort); clipped layers preserve the transparency underneath. +//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart +//! objects whose file is embedded become footage of that file (an embedded Photoshop document's +//! merged image, or the embedded image) placed with the smart object's transform. use std::sync::Arc; @@ -164,6 +166,60 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( (id, (w, h)) } +/// Size of an embedded file's image: the smart object's recorded size, else the embedded +/// document's header (Photoshop, PNG). +fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> { + if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) { + return Some((w.round() as u32, h.round() as u32)); + } + if effectcraft_psd::is_psd(data) && data.len() >= 22 { + let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]); + let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]); + return Some((w, h)); + } + if data.starts_with(b"\x89PNG") && data.len() >= 24 { + let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]); + let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]); + return Some((w, h)); + } + None +} + +/// A smart object layer as footage of its embedded file, placed by the smart object's +/// transform. `None` when the file is not embedded (linked externally) or unreadable. +fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option { + let pl = &cx.psd.layers[index]; + let so = pl.smart_object.clone()?; + let data = cx.psd.linked_data(&so.uuid)?; + let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?; + let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: cx.rate, + has_video: true, + alpha: AlphaMode::Straight, + loop_count: 1, + codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() }, + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }), + ..Default::default() + }; + let label = if file.is_empty() { name.to_string() } else { file }; + let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None); + let (c, sx, sy, rot) = so.placement(w as f64, h as f64); + set_vec2(&mut l, "transform/position", c); + set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]); + if rot.abs() > 1e-9 { + set_scalar(&mut l, "transform/rotation", rot); + } + Some(l) +} + fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option { let (index, children) = match n { Node::Layer(i) => (*i, None), @@ -183,6 +239,8 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio let mut l = adjustment_layer(proj, comp, &name); add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name); l + } else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) { + l } else if pl.has_pixels() || pl.fill.is_some() { let (item, size) = footage_item(proj, cx, index, &name); let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None); diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 7b516a2a3..9b77ccae4 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -281,6 +281,43 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { assert!(p[2] > p[0], "only sky under the art {p:?}"); } +#[test] +fn psd_smart_object_imports_its_embedded_document() { + // The embedded document: red left half, green right half (20×10). + let mut inner = WDoc::new(20, 10); + inner.layers = + vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])]; + let inner_bytes = write(&inner); + let mut d = WDoc::new(64, 48); + // Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4). + let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]]; + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + // Placeholder pixels (grey) that the embedded file replaces. + WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])), + ]; + d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)]; + let path = tmp("smart.psd"); + std::fs::write(&path, write(&d)).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap(); + let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") }; + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert_eq!((f.width, f.height), (20, 10)); + assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1")); + assert!(s.project.item(item).unwrap().name.contains("inner.psd")); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + // Rotated a quarter clockwise: the red half is on top (rows 4–24), green below. + let (red, green) = (img.get(30, 14), img.get(30, 34)); + assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}"); + assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}"); + assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside"); +} + #[test] fn eps_footage_imports() { let eps = diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 111140a4c..b2331d812 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -67,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result> { if effectcraft_psd::is_psd(bytes) { let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + // A smart object's embedded file: an embedded document's merged image, or an image. + if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) { + let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?; + if effectcraft_psd::is_psd(data) { + let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); + } + let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + return Ok(Some(crate::convert::dynamic_to_image(&img, op))); + } let px = match &footage.layer { Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size), None => psd.composite(), diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ed6521c91..a0864f085 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -386,9 +386,10 @@ impl Inner { let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count); Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } } - // A layer of a layered still is keyed by its layer index and size mode. + // A layer of a layered still is keyed by its layer index and size mode (smart objects + // by their embedded file). FootageKind::Still if footage.layer.is_some() => { - let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64); + let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 }); Loc { key: key(&footage.path, l), media_t: None } } FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None }, diff --git a/crates/psd/README.md b/crates/psd/README.md index e238b9b2f..b0b19ba3b 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -8,10 +8,14 @@ Implemented from Adobe's public **Adobe Photoshop File Formats Specification** ( edition): file header, colour mode data, image resources, layer and mask information (layer records, channel image data, layer mask data, additional layer information: `luni`, `lsct`/`lsdk`, `lyid`, `iOpa`, `TySh`, `lfx2`, `SoCo`, `nvrt`, `brit`, `hue2`, `levl`, `expA`, `vmsk`/`vsms`, -`Lr16`/`Lr32`), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits) +`Lr16`/`Lr32`, smart objects: `SoLd`/`SoLE`/`PlLd` placement and `lnk2`/`lnkD`/`lnk3`/`lnkE` +linked layer data with embedded files), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits) and ZIP (with and without prediction, inflated with `miniz_oxide`) compression; 1/8/16/32-bit; Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes. Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and -justification of the first style run. No Adobe code, sample files or assets were used; test +justification of the first style run. Smart objects import as footage of their embedded file +(a Photoshop document's merged image, or an image) placed by the smart object's corner quad +(position, scale, rotation; perspective and warps are not applied). Externally linked files +fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)). diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 885ab3c21..61cadc7d9 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -204,6 +204,46 @@ pub struct SubPath { pub knots: Vec<[[f64; 2]; 3]>, } +/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SmartObject { + /// Unique id of the linked file ([`Psd::linked`]) the layer shows. + pub uuid: String, + /// Where the content's corners sit in document pixels: top-left, top-right, bottom-right, + /// bottom-left. + pub quad: [[f64; 2]; 4], + /// The content's size in pixels (`Sz `), when recorded. + pub size: Option<[f64; 2]>, +} + +impl SmartObject { + /// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale + /// y, rotation in degrees). Perspective and skew are dropped. + pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) { + let q = self.quad; + let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0]; + let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]]; + let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]]; + let sx = top[0].hypot(top[1]) / w.max(1e-9); + let sy = left[0].hypot(left[1]) / h.max(1e-9); + // Mirrored content (left edge turning the other way) keeps a negative y scale. + let cross = top[0] * left[1] - top[1] * left[0]; + (centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees()) + } +} + +/// A file embedded in (or linked from) the document: linked layer data (`lnk2`, `lnkD`, +/// `lnk3`, `lnkE`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LinkedFile { + pub uuid: String, + pub name: String, + /// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank. + pub file_type: String, + /// Embedded data (absent for externally linked files). + pub data: Option>, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct Layer { /// Index in [`Psd::layers`] (file order: bottom of the stack first). @@ -230,6 +270,8 @@ pub struct Layer { /// Layer effects (`lfx2` descriptor). pub effects: Option, pub vector_mask: Option, + /// Smart object placement. + pub smart_object: Option, /// Keys of every additional-information block the layer carries. pub keys: Vec<[u8; 4]>, } @@ -277,6 +319,8 @@ pub struct Psd { pub merged_alpha: bool, /// Indexed colour palette (256 R, then G, then B). pub palette: Vec, + /// Files embedded for smart objects. + pub linked: Vec, data: Arc<[u8]>, merged_offset: usize, } @@ -314,7 +358,20 @@ impl Psd { let n = r.u32()? as usize; r.skip(n)?; // Layer and mask information. - let mut psd = Psd { psb, width, height, channels, depth, mode, layers: vec![], merged_alpha: false, palette, data: data.clone(), merged_offset: 0 }; + let mut psd = Psd { + psb, + width, + height, + channels, + depth, + mode, + layers: vec![], + merged_alpha: false, + palette, + linked: vec![], + data: data.clone(), + merged_offset: 0, + }; let lm_len = r.length(psb)?; let lm_end = r.pos.checked_add(lm_len).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?; if lm_len > 0 { @@ -330,6 +387,9 @@ impl Psd { } // Global additional layer information (16/32-bit layer info lives here). while let Some((key, start, end)) = next_block(&data, &mut r.pos, lm_end, psb) { + if matches!(&key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE") { + psd.linked_files(&data, start, end); + } if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") && psd.layers.is_empty() && end > start { // The block holds the layer info, normally without its length field; accept // both layouts. @@ -348,6 +408,67 @@ impl Psd { Ok(psd) } + /// The embedded bytes of linked file `uuid`. + pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> { + let f = self.linked.iter().find(|f| f.uuid == uuid)?; + self.data.get(f.data.clone()?) + } + + /// Linked layer data records (spec: "Linked Layer"): length, kind (`liFD` embedded data, + /// `liFE` external file, `liFA` alias), version, unique id, original file name, type, + /// creator, data length, optional descriptors, then the data. A damaged record ends the list. + fn linked_files(&mut self, data: &[u8], start: usize, end: usize) { + let mut pos = start; + while pos + 8 <= end { + let mut r = Reader::at(&data[..end], pos); + let Ok(len) = r.u64() else { break }; + let body = r.pos; + let Some(rec_end) = body.checked_add(len as usize).filter(|e| *e <= end) else { break }; + let mut r = Reader::at(&data[..rec_end], body); + let rec = (|| -> Result { + let kind = r.tag()?; + let version = r.u32()?; + let n = r.u8()? as usize; + let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned(); + let name = descriptor::read_unicode(&mut r)?; + let file_type = String::from_utf8_lossy(&r.tag()?).trim().to_string(); + let _creator = r.tag()?; + let dlen = r.u64()? as usize; + if r.u8()? != 0 { + let _v = r.u32()?; + descriptor::read_descriptor(&mut r)?; + } + let mut out = LinkedFile { uuid, name, file_type, data: None }; + match &kind { + b"liFD" => { + let s = r.pos; + r.skip(dlen)?; + out.data = Some(s..s + dlen); + } + b"liFE" => { + let _v = r.u32()?; + descriptor::read_descriptor(&mut r)?; + if version > 3 { + r.skip(4 + 4 + 8)?; + } + let _size = r.u64()?; + if version > 2 && r.remaining() >= dlen && dlen > 0 { + let s = r.pos; + out.data = Some(s..s + dlen); + } + } + _ => {} + } + Ok(out) + })(); + match rec { + Ok(f) => self.linked.push(f), + Err(_) => break, + } + pos = rec_end.div_ceil(4) * 4; + } + } + fn layer_info(&mut self, data: &[u8], start: usize, end: usize) -> Result<()> { let mut r = Reader::at(&data[..end], start); let count = r.i16()?; @@ -522,6 +643,46 @@ impl Psd { l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); } } + b"SoLd" | b"SoLE" => { + let _id = r.tag()?; + let _ver = r.u32()?; + let _dver = r.u32()?; + let d = descriptor::read_descriptor(&mut r)?; + let uuid = d.text("Idnt").unwrap_or_default().to_string(); + let nums = |k: &str| -> Option> { + match d.get(k)? { + DValue::List(l) => Some( + l.iter() + .filter_map(|v| match v { + DValue::Double(x) | DValue::UnitFloat(_, x) => Some(*x), + DValue::Integer(i) => Some(*i as f64), + _ => None, + }) + .collect(), + ), + DValue::UnitFloats(_, v) => Some(v.clone()), + _ => None, + } + }; + let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]); + let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?])); + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size }); + } + b"PlLd" if l.smart_object.is_none() => { + let _id = r.tag()?; + let _ver = r.u32()?; + let n = r.u8()? as usize; + let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned(); + let _page = r.u32()?; + let _pages = r.u32()?; + let _aa = r.u32()?; + let _ty = r.u32()?; + let mut t = [0.0; 8]; + for v in &mut t { + *v = r.f64()?; + } + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None }); + } b"vmsk" | b"vsms" => { let _ver = r.u32()?; let flags = r.u32()?; diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 68680fe71..cd2f92938 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -187,3 +187,34 @@ fn truncated_and_garbage_input_errors() { assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err()); assert!(Psd::parse(b"PNG".to_vec()).is_err()); } + +#[test] +fn smart_objects_and_embedded_files() { + let mut inner = WDoc::new(20, 10); + inner.layers = vec![WLayer::solid("Red", Rect::new(0, 0, 20, 10), [1.0, 0.0, 0.0, 1.0])]; + let inner_bytes = write(&inner); + let mut d = WDoc::new(64, 48); + // The content scaled 2× and rotated 90° clockwise: top-left at (40, 4). + let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]]; + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("uuid-1", quad, [20.0, 10.0])), + ]; + d.linked = vec![("uuid-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())]; + let p = Psd::parse(write(&d)).unwrap(); + assert_eq!(p.linked.len(), 1); + assert_eq!(p.linked[0].name, "inner.psd"); + assert_eq!(p.linked[0].file_type, "8BPS"); + assert_eq!(p.linked_data("uuid-1").unwrap(), &inner_bytes[..]); + let so = p.layers[1].smart_object.clone().unwrap(); + assert_eq!(so.uuid, "uuid-1"); + assert_eq!(so.quad, quad); + assert_eq!(so.size, Some([20.0, 10.0])); + let (c, sx, sy, rot) = so.placement(20.0, 10.0); + assert_eq!(c, [30.0, 24.0]); + assert!((sx - 2.0).abs() < 1e-9 && (sy - 2.0).abs() < 1e-9 && (rot - 90.0).abs() < 1e-9, "{sx} {sy} {rot}"); + // The embedded document parses on its own. + let e = Psd::parse(p.linked_data("uuid-1").unwrap().to_vec()).unwrap(); + assert_eq!((e.width, e.height), (20, 10)); + assert!(p.layers[0].smart_object.is_none()); +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index fb78cc78e..160e81d30 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -103,11 +103,14 @@ pub struct WDoc { /// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers /// with Normal blending. pub composite: Option>, + /// Embedded files for smart objects: (unique id, file name, type code, bytes), written as + /// linked layer data (`lnk2`). + pub linked: Vec<(String, String, [u8; 4], Vec)>, } impl WDoc { pub fn new(width: u32, height: u32) -> WDoc { - WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None } + WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None, linked: vec![] } } } @@ -339,6 +342,13 @@ pub fn write(doc: &WDoc) -> Vec { lm.extend_from_slice(&li); u32b(&mut lm, 0); } + if !doc.linked.is_empty() { + let d = linked_block(&doc.linked); + lm.extend_from_slice(b"8BIM"); + lm.extend_from_slice(b"lnk2"); + u32b(&mut lm, d.len() as u32); + lm.extend_from_slice(&d); + } } u32b(&mut out, lm.len() as u32); out.extend_from_slice(&lm); @@ -372,8 +382,53 @@ pub fn write(doc: &WDoc) -> Vec { out } +/// Linked layer data records (`liFD`, version 7) for embedded files. +fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { + let mut out = vec![]; + for (uuid, name, ty, data) in files { + let mut rec = vec![]; + rec.extend_from_slice(b"liFD"); + u32b(&mut rec, 7); + rec.push(uuid.len() as u8); + rec.extend_from_slice(uuid.as_bytes()); + rec.extend_from_slice(&unicode_plain(name)); + rec.extend_from_slice(ty); + rec.extend_from_slice(b"8BIM"); + rec.extend_from_slice(&(data.len() as u64).to_be_bytes()); + rec.push(0); // no file-open descriptor + rec.extend_from_slice(data); + u32b(&mut rec, 0); // child document id (empty unicode string) + rec.extend_from_slice(&0f64.to_be_bytes()); // asset modification time + rec.push(0); // asset locked state + out.extend_from_slice(&(rec.len() as u64).to_be_bytes()); + out.extend_from_slice(&rec); + while !out.len().is_multiple_of(4) { + out.push(0); + } + } + out +} + // ---------------------------------------------------------------- block helpers +/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at +/// `quad` (top-left, top-right, bottom-right, bottom-left). +pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec) { + let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect()); + let t: Vec = quad.iter().flat_map(|p| [p[0], p[1]]).collect(); + let d = Descriptor::new("null") + .with("Idnt", DValue::Text(uuid.to_string())) + .with("placed", DValue::Text(uuid.to_string())) + .with("Trnf", list(&t)) + .with("nonAffineTransform", list(&t)) + .with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1])))); + let mut out = b"soLD".to_vec(); + u32b(&mut out, 4); + u32b(&mut out, 16); + descriptor::write_descriptor(&mut out, &d); + (*b"SoLd", out) +} + /// `TySh`: a type layer with one style run; the text origin sits at `origin`. pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec) { let mut d = vec![]; From 054ce4a0fbf6ca94d7fc719545111a448b065edd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:18:30 -0400 Subject: [PATCH 19/20] M13.2: docs (parity, architecture) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 1 + docs/parity.md | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a54703b71..7fc1bb5a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors | | L2 | `effects` | The effect registry (241 effects) and their CPU implementations | | L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale | +| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, clipping, optional-content layers | | L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation | | L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves | | L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown | diff --git a/docs/parity.md b/docs/parity.md index 4437becec..68717920d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,8 +21,8 @@ Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; -approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable -font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; +approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; text in imported +PDF/AI files and Illustrator procset EPS; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). @@ -111,14 +111,14 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | | Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) | | Audio | 85% | 0.5 | audio to keyframes | -| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) | +| Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | | Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | -| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | +| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | -| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | +| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | From e1296a57c2c14b366e49d50b7c2e3d67daa16bcf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:22:49 -0400 Subject: [PATCH 20/20] M13.2: Create VR Environment nests the scene as a collapsed 3D precomp in each face comp (lights copied) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 61 +++++++++++++++----------- crates/engine/src/tests_m145.rs | 5 ++- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 6431d5e23..fb0473d4f 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -433,8 +433,8 @@ fn ae(v: [f64; 3]) -> Vec3 { } /// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every -/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a -/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp +/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, looking +/// at the scene nested as a collapsed 3D precomp), a 3:2 cube map of them, and an equirectangular output comp /// (VR Converter, cube map → equirectangular). One undo step. fn create_vr_environment(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; @@ -462,30 +462,41 @@ fn create_vr_environment(s: &mut Session, p: &Value) -> Result { id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?; s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?; s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?; - // The scene's layers (not its cameras), in the scene's coordinates, under the face - // camera. - let layers: Vec = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect(); - s.edit("Create VR Environment", None, |proj, _| { - let mut next = proj.next_id; - let mut map = std::collections::BTreeMap::new(); - let mut copies = vec![]; - for l in &layers { - let mut c = l.clone(); - super::edit::reid(&mut c, &mut next); - map.insert(l.id, c.id); - copies.push(c); - } - for c in &mut copies { - c.parent = c.parent.and_then(|p| map.get(&p).copied()); - if let Some(m) = &mut c.track_matte { - m.layer = map.get(&m.layer).copied().unwrap_or(m.layer); + // The scene, nested as a collapsed 3D precomp whose layer maps the scene's + // coordinates onto the face comp's unchanged (anchor = position): its 3D layers are + // seen through the face camera and edits to the scene show in every face. Lights + // inside a collapsed precomp do not light it (the containing comp's do), so the + // scene's lights are copied next to it. + let pre = id_of(&s.execute("layer.addItem", json!({"comp": fc, "item": cid.0, "time": 0.0}))?, "layer")?; + let mid = [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0]; + s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "threeD", "value": true}))?; + s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "collapse", "value": true}))?; + s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/anchor", "value": mid}))?; + s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/position", "value": mid}))?; + let lights: Vec = src.layers.iter().filter(|l| l.is_light()).cloned().collect(); + if !lights.is_empty() { + s.edit("Create VR Environment", None, |proj, _| { + let mut next = proj.next_id; + let mut map = std::collections::BTreeMap::new(); + let mut copies = vec![]; + for l in &lights { + let mut c = l.clone(); + super::edit::reid(&mut c, &mut next); + map.insert(l.id, c.id); + copies.push(c); } - } - proj.next_id = next; - let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?; - comp.layers.extend(copies); - Ok(()) - })?; + for c in &mut copies { + c.parent = c.parent.and_then(|p| map.get(&p).copied()); + } + proj.next_id = next; + let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?; + let at = comp.layers.len().saturating_sub(1); + for (k, c) in copies.into_iter().enumerate() { + comp.layers.insert(at + k, c); + } + Ok(()) + })?; + } faces.push(fc); } let cube = id_of( diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index edc8fe180..d538da63f 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -476,8 +476,9 @@ fn create_vr_environment_and_extract_cubemap() { let c = s.project.comp(ItemId(*f)).unwrap(); assert_eq!((c.width, c.height), (64, 64)); assert!(c.layers.iter().any(|l| l.is_camera())); - assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers"); - let _ = main; + let nested = c.layers.iter().find(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == main)).expect("the scene, nested"); + assert!(nested.switches.collapse && nested.is_3d(), "collapsed 3D precomp"); + let _ = &aname; } let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));