diff --git a/Cargo.lock b/Cargo.lock index 1dcfbb95..4b8e0547 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", @@ -1310,6 +1311,7 @@ dependencies = [ "effectcraft-export", "effectcraft-expr", "effectcraft-media", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1353,6 +1355,7 @@ name = "effectcraft-media" version = "0.1.0" dependencies = [ "effectcraft-model", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1399,6 +1402,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 22709f60..cda7c302 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/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 4f2192fc..8aa1381b 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,19 +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}", "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(); + 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); @@ -789,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); @@ -797,7 +845,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/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index eea7ffaa..06ab9785 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 b6426f5f..157ffba2 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/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..3b84d283 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/distort2.rs b/crates/effects/src/distort2.rs index 66f038e0..2d52f039 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/generate.rs b/crates/effects/src/generate.rs index 511a8fea..09b25a27 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 19a9b6a3..ddde36d7 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 3457e576..c4a47508 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -41,6 +41,7 @@ pub mod ocio_config; pub mod paint; mod perspective; mod perspective2; +pub mod psim; pub mod puppet; pub mod roto; mod sim; @@ -63,11 +64,20 @@ 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}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use sim::particle_state; +pub use sim3::playground_state; +// 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] = &[ @@ -261,6 +271,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"). @@ -642,6 +657,42 @@ 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", + // 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", + // 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/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a..4181c070 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/psim.rs b/crates/effects/src/psim.rs new file mode 100644 index 00000000..aabbcdce --- /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 7f90af6b..18beeb76 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 5560917a..f7425df7 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/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449..9701f0c4 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/engine/Cargo.toml b/crates/engine/Cargo.toml index 45508a71..7a7adb30 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/align_data.rs b/crates/engine/src/commands/align_data.rs new file mode 100644 index 00000000..1f18de5f --- /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/animation.rs b/crates/engine/src/commands/animation.rs index a6390504..aa74c3f2 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 00000000..fb0473d4 --- /dev/null +++ b/crates/engine/src/commands/app_more.rs @@ -0,0 +1,604 @@ +//! 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", + Default => "view.3d.default", + 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, 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)?; + 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, 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); + } + 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( + &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/autotrace.rs b/crates/engine/src/commands/autotrace.rs new file mode 100644 index 00000000..093468b6 --- /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 00000000..b030a991 --- /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/create.rs b/crates/engine/src/commands/create.rs index 72cb2420..a1d22fb5 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/edit.rs b/crates/engine/src/commands/edit.rs index 03cd8087..44f49466 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 5a69a213..fe057683 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), @@ -101,6 +111,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; @@ -126,6 +147,8 @@ 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 { // Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project. if let Some(f) = data_footage(s, path) { @@ -141,6 +164,20 @@ fn import(s: &mut Session, p: &Value) -> Result { }; 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; @@ -153,7 +190,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; @@ -165,6 +204,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()); @@ -178,8 +218,12 @@ fn import(s: &mut Session, p: &Value) -> Result { FootageKind::Video | FootageKind::Model => Label::Aqua, FootageKind::Data => Label::Sandstone, }; + 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(()) @@ -187,7 +231,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). @@ -201,7 +282,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; @@ -217,6 +298,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 +478,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), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs new file mode 100644 index 00000000..4d11a35a --- /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 168e77a5..7abd2923 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/keys_more.rs b/crates/engine/src/commands/keys_more.rs new file mode 100644 index 00000000..a64584f4 --- /dev/null +++ b/crates/engine/src/commands/keys_more.rs @@ -0,0 +1,251 @@ +//! Keyframe features of M14.5: Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes and +//! RPF Camera Import, and Edit ▸ Label on selected keyframes. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::LayerId; +use effectcraft_project::build::Ids; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::app_more::grouped; +use super::{CommandSpec, bad, comp_id, has_comp, 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 (label names as renamed +/// in Settings ▸ Labels; `keys.setLabel` is the Timeline context menu's command). +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})) +} + +// ---------------------------------------------------------------- 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 + ), + ] +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 29b4c595..58135b35 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -826,12 +826,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; @@ -913,6 +914,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. @@ -1157,7 +1163,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/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index b441c366..263d0d58 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 1e070ab5..3892bd06 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 da1e9c33..359de14d 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), feather: Vec::new() }; 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,13 +180,13 @@ 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); sp.feather_after_insert(i, None); - Ok(i) + Ok((i, Some(CountOp::Insert(i)))) })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; Ok(json!(i)) @@ -197,9 +268,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 { @@ -214,6 +292,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(()) })?; @@ -233,7 +314,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(); @@ -525,7 +606,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 4112cf6c..d9e4feec 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,12 +1,16 @@ //! 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 app_more; +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,10 +19,12 @@ pub mod expr_tools; mod file; pub(crate) mod file_more; mod focus; +pub mod footage_panel; mod frontend; mod help; mod key_labels; mod key_transform; +mod keys_more; mod layer; mod layer_menu; mod layer_time; @@ -30,6 +36,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; @@ -40,6 +47,7 @@ mod query; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; +mod scene_detect; mod settings; mod shape_stroke; mod stubs; @@ -53,6 +61,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; @@ -155,9 +170,17 @@ 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(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 }) @@ -257,12 +280,15 @@ pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), Stri if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) } } -/// Disabled-command enablement (menu entries whose implementation lands with another milestone). +/// Disabled-command enablement (menu entries whose implementation lands with another milestone; +/// none are left, kept for future stubs). +#[allow(dead_code)] pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> { Err("not available yet in EffectCraft".into()) } /// `run` of a not-yet-available command. +#[allow(dead_code)] pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result { Err(EngineError::Other("not available yet in EffectCraft".into())) } diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs new file mode 100644 index 00000000..eb056878 --- /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/prop.rs b/crates/engine/src/commands/prop.rs index 2f34898c..f0ada926 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 fcf088ac..365d7e55 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -707,6 +707,7 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { path: &path, storage: s.storage_quota.as_deref(), label: "Save Current Preview".into(), + 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)); diff --git a/crates/engine/src/commands/scene_detect.rs b/crates/engine/src/commands/scene_detect.rs new file mode 100644 index 00000000..8d523e21 --- /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 2751fbc3..b868828d 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -4,8 +4,10 @@ //! always disabled. When a feature lands, delete its line here and register the real command //! with the same id (the menu tree in `menus.rs` already points at it). +#[allow(unused_imports)] use super::{CommandSpec, not_yet, not_yet_run}; +#[allow(unused_macros)] macro_rules! stub { ($id:literal, $label:literal, [$($m:literal),*], $params:literal) => { CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true } @@ -16,19 +18,6 @@ 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). - // 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}"), - ] + // Every After Effects menu entry has a real command now. + vec![] } diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 28048389..37ed9da0 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/jobs.rs b/crates/engine/src/jobs.rs new file mode 100644 index 00000000..39a16915 --- /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 d7a89160..a890276f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,8 +13,12 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; +pub mod jobs; pub mod links; +pub mod logging; pub mod mask_track; +pub mod media_browser; +pub mod media_cache; pub mod menus; pub mod offload; pub mod prefs; @@ -23,10 +27,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; @@ -43,6 +49,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 +223,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, /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. #[serde(default)] pub locked_viewer: Option, @@ -322,11 +338,21 @@ 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, /// Runs renders and analyses off the UI thread where there are no threads (the web app's /// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32). 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`]). @@ -382,8 +408,13 @@ impl Default for Session { autosave: autosave::AutoSaveState::default(), snapshot: None, script: None, + sys_memory: None, + applied_nested_switches: None, offload: None, offloaded: vec![], + tasks: vec![], + job_log: vec![], + next_task_id: 1, } } } @@ -551,6 +582,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); + } } } @@ -592,6 +664,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); @@ -640,6 +713,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); @@ -707,6 +781,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; @@ -750,7 +826,76 @@ 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() +} + +/// 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)] +mod tests_panels; +#[cfg(test)] mod tests_roto; diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs new file mode 100644 index 00000000..23ef28f0 --- /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_browser.rs b/crates/engine/src/media_browser.rs new file mode 100644 index 00000000..afc7dc34 --- /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/media_cache.rs b/crates/engine/src/media_cache.rs new file mode 100644 index 00000000..1a9b932a --- /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 51fa4e02..9ab366db 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) //! ``` //! @@ -35,11 +37,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, @@ -81,7 +192,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); @@ -97,7 +208,27 @@ 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()), + // The footage shown in the Footage panel (else the selected footage). + Some("footage") => { + let shown = s.state.footage_panel.as_ref().and_then(|f| s.project.item(f.item)); + let f = shown.or_else(|| { + 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())) + } + _ => e.label.clone(), + }, "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(), @@ -109,6 +240,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); @@ -248,6 +382,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() { @@ -333,6 +471,8 @@ File New Folder | project.newFolder Open Project... | file.open Open Recent + @dynamic:recentProjects + --- Clear Recent Projects | file.clearRecent --- Close | file.close @@ -341,6 +481,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 --- @@ -351,6 +492,10 @@ File Solid... | file.importSolid Lottie... | file.importLottie Essential Graphics Template... | essential.importTemplate + Import Recent Footage + @dynamic:recentFootage + --- + Clear Recent Footage | file.clearRecentFootage Export Add to Render Queue | renderQueue.add Lottie JSON... | file.exportLottie @@ -388,6 +533,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 --- @@ -397,6 +543,8 @@ File Edit Undo | edit.undo Redo | edit.redo + History + @dynamic:history --- Quick Apply... | app.commandPalette --- @@ -508,6 +656,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 @@ -760,6 +912,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"} @@ -922,6 +1078,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 @@ -951,24 +1109,26 @@ 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 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"} @@ -979,11 +1139,12 @@ 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 Timeline | window.panel {"panel":"timeline"} + @dynamic:openViewers Help EffectCraft Help... | help.docs {"page":"help"} | F1 Scripting Help... | help.docs {"page":"scripting"} @@ -992,6 +1153,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 ee8790c7..6fdbd2a7 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 2d744226..8a484601 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}; @@ -273,6 +274,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, @@ -301,6 +309,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(), } } @@ -430,7 +441,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 { @@ -490,6 +507,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 { @@ -659,13 +844,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 { @@ -673,7 +859,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: "{}" }, @@ -712,13 +898,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 { @@ -726,10 +913,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), s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true), Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" }, ], @@ -742,9 +929,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), ], @@ -757,13 +944,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), @@ -774,10 +961,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 { @@ -787,7 +974,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", @@ -798,13 +985,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, ), ], }, @@ -812,12 +999,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 { @@ -826,7 +1013,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), @@ -837,36 +1024,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 { @@ -874,9 +1061,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 { @@ -884,18 +1071,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/psd_import.rs b/crates/engine/src/psd_import.rs index f64fd3af..555dd97b 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; @@ -156,7 +158,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)); @@ -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/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 3de9d8e5..5e77131c 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::{Deserialize, Serialize}; @@ -31,6 +31,8 @@ pub struct ExportJob<'a> { pub storage: Option<&'a dyn effectcraft_project::render_queue::StorageQuota>, /// "#n Comp Name", for the render log. pub label: String, + /// Settings ▸ General ▸ Switches Affect Nested Comps. + pub nested_switches: bool, } /// What an export wrote. @@ -138,8 +140,56 @@ struct Work { accel: Option>, exporter: Arc, storage: Option>, - /// 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 { @@ -162,6 +212,7 @@ pub(crate) fn run_items( exporter, storage: s.storage_quota.clone(), items, + nested_switches: s.prefs.general.switches_affect_nested_comps, }; run_work(work, shared, notify); } @@ -204,6 +255,7 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) { path, storage: w.storage.as_deref(), label: format!("#{index} {name} (output module {})", mi + 1), + nested_switches: w.nested_switches, }; let rr = w.exporter.export(&job, &mut |done, total| { { @@ -277,12 +329,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()) @@ -315,7 +413,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), @@ -366,6 +470,7 @@ impl Session { exporter, storage: self.storage_quota.clone(), items, + nested_switches: self.prefs.general.switches_affect_nested_comps, }; let shared = Arc::new(JobShared::default()); if wait { diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 1a3370e4..d68496a2 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 00000000..0c79c2a1 --- /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 00000000..d538da63 --- /dev/null +++ b/crates/engine/src/tests_m145.rs @@ -0,0 +1,667 @@ +//! 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())); + 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)); + 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", "visible": [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.panel", "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/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 4456a12c..cc085447 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { + for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs new file mode 100644 index 00000000..d989b28e --- /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/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 18e65cb0..decd8a72 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -449,7 +449,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); } // What remains a stub is still disabled. - for id in ["layer.autoTrace", "keys.audioToKeyframes"] { + for id in ["layer.autoTrace", "layer.sceneEditDetection"] { assert!(!s.is_enabled(id), "{id}"); } } diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 2476727f..46610786 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/engine/src/vector.rs b/crates/engine/src/vector.rs index a3f3e040..3465d385 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/engine/src/xml_project.rs b/crates/engine/src/xml_project.rs new file mode 100644 index 00000000..ae899d46 --- /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 74b12c76..f1e3269f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -79,6 +79,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, /// Render log, storage overflow. pub options: JobOptions<'a>, } diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 76087f2a..d1ad01bf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -86,6 +86,8 @@ impl<'a> Cx<'a> { view: None, backend: effectcraft_render::Backend::Auto, roi: None, + nested_switches: self.job.nested_switches, + draft_shadows: true, proxy: s.proxy_use, }; let mut r = Renderer::new(&self.project, self.job.footage, opts); diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/render_settings.rs index 3347efe1..c59411b3 100644 --- a/crates/export/tests/render_settings.rs +++ b/crates/export/tests/render_settings.rs @@ -46,7 +46,19 @@ fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, pat let files: Files = Default::default(); let f = files.clone(); let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); - let job = Job { project: p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: s, output: om, path, sink: Some(&*sink), options }; + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: s, + output: om, + path, + sink: Some(&*sink), + options, + nested_switches: true, + }; let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string()); (r, files) } diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index c6810cb0..4c2f6bb4 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -55,7 +55,19 @@ 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, options: Default::default() }; + let job = Job { + project: p, + footage, + expr: None, + accel: None, + comp: cid, + settings: &s, + output: om, + path: &path, + sink: None, + options: Default::default(), + nested_switches: true, + }; let mut last = Progress::default(); let mut calls = 0; let r = export(&job, &mut |pr| { @@ -178,6 +190,7 @@ fn h264_odd_size_is_cropped_even() { path: &path, sink: None, options: Default::default(), + nested_switches: true, }; let r = export(&job, &mut |_| true).expect("export"); assert_eq!((r.width % 2, r.height % 2), (0, 0)); @@ -309,6 +322,7 @@ fn cancel_stops_early() { path: &path, sink: None, options: Default::default(), + nested_switches: true, }; let r = export(&job, &mut |_| false); assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs new file mode 100644 index 00000000..0c72dd60 --- /dev/null +++ b/crates/gpu/src/classic3d.rs @@ -0,0 +1,233 @@ +//! 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, bokeh depth of field), the GPU 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 layer uploads (cached per buffer; bokeh depth of field is already in + // them). + let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len()); + for p in &run.planes { + texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset)); + } + 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 { + 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 46868104..d6f38883 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -26,8 +26,13 @@ const ENTRIES: &[&str] = &[ "shadow_make", "shadow_combine", "pointwise", + "adjust_mix", ]; +/// 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 +93,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, @@ -96,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 { @@ -135,7 +144,15 @@ 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"), + 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 { @@ -167,12 +184,32 @@ 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| { + .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)| { 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 +258,7 @@ impl GpuContext { name, max_dim, bgl, + bgl_ext, pipelines, display_bgl, display, @@ -228,6 +266,8 @@ impl GpuContext { dummy_buf, uploads: Mutex::new(Uploads::default()), adv3d: std::sync::OnceLock::new(), + particles: Default::default(), + particle_states: Default::default(), }) } @@ -374,6 +414,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 +451,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 +596,33 @@ impl<'g> Enc<'g> { for x in v { bytes.extend_from_slice(&x.to_le_bytes()); } - while bytes.len() < 16 { + 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 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"), + 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().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 fdc317b3..2ef75c48 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(()); } @@ -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), @@ -72,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), } } @@ -79,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]; } @@ -95,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 { @@ -111,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 00000000..824f9a50 --- /dev/null +++ b/crates/gpu/src/fx_color.rs @@ -0,0 +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, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_color.wgsl`. +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] = &[ + "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 { + 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/fx_distort.rs b/crates/gpu/src/fx_distort.rs new file mode 100644 index 00000000..bcd7214f --- /dev/null +++ b/crates/gpu/src/fx_distort.rs @@ -0,0 +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, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_distort.wgsl`. +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] = &[ + "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 { + 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/fx_generate.rs b/crates/gpu/src/fx_generate.rs new file mode 100644 index 00000000..976bfc8e --- /dev/null +++ b/crates/gpu/src/fx_generate.rs @@ -0,0 +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_color::BlendMode; +use effectcraft_effects::{EffectCtx, GrainLook}; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_generate.wgsl`. +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] = &[ + "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 { + 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 43474e9d..bf47e3c5 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,15 +18,24 @@ //! 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 //! egui-wgpu to draw. mod adv3d; +mod classic3d; mod context; mod effects; +mod fx_color; +mod fx_distort; +mod fx_generate; mod ops; +mod particles; mod walk; use std::sync::Arc; @@ -135,9 +146,31 @@ 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)] mod tests; #[cfg(test)] +mod tests_3d; +#[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_color; +#[cfg(test)] +mod tests_fx_distort; +#[cfg(test)] +mod tests_fx_generate; +#[cfg(test)] +mod tests_particles; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index f8248f99..42bd9f37 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.image_rows(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/particles.rs b/crates/gpu/src/particles.rs new file mode 100644 index 00000000..669a4ba0 --- /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/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl new file mode 100644 index 00000000..4e8d2139 --- /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/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl new file mode 100644 index 00000000..97771a46 --- /dev/null +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -0,0 +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/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl new file mode 100644 index 00000000..a5493621 --- /dev/null +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -0,0 +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/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl new file mode 100644 index 00000000..e5e2f411 --- /dev/null +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -0,0 +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/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index c94076a0..443abcf9 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/shaders/particles.wgsl b/crates/gpu/src/shaders/particles.wgsl new file mode 100644 index 00000000..694d6fb6 --- /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.rs b/crates/gpu/src/tests.rs index c6fd84f0..f795b58d 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). @@ -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] { @@ -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 00000000..f10368d2 --- /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/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs new file mode 100644 index 00000000..ae7f88da --- /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/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs new file mode 100644 index 00000000..8fb455b8 --- /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/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs new file mode 100644 index 00000000..9732f99c --- /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); + } + } +} diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs new file mode 100644 index 00000000..e15abb37 --- /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/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs new file mode 100644 index 00000000..e3ef97a8 --- /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/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 6051b4aa..edef7ef1 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; @@ -77,7 +78,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]) { @@ -94,6 +95,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 { @@ -113,9 +133,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); @@ -151,6 +176,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/host/Cargo.toml b/crates/host/Cargo.toml index 635e3260..564efb3b 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/src/lib.rs b/crates/host/src/lib.rs index 4161abc7..41ece11e 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -46,6 +46,7 @@ impl Exporter for FileExporter { storage: job.storage, overflow: job.project.render_prefs.overflow_folders.clone(), }, + nested_switches: job.nested_switches, }; match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { Ok(r) => Ok(ExportResult { diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 590112c9..9b77ccae 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -184,3 +184,153 @@ 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 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 = + 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/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index ec2b4754..bf34d592 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. @@ -520,6 +520,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/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 2e9d5c6e..ae0b2e56 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/media/Cargo.toml b/crates/media/Cargo.toml index 10998031..3db4b1a9 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 204312a5..b2331d81 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) } @@ -35,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(), @@ -42,8 +85,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 +96,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 6c67f50f..5de2eb8c 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 9bdefcb6..13010057 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -131,6 +131,12 @@ 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. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + conforming: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -193,6 +199,8 @@ impl MediaPool { hits: AtomicU64::new(0), misses: AtomicU64::new(0), prefetched: AtomicU64::new(0), + conform: Mutex::default(), + conforming: Mutex::default(), }), } } @@ -257,14 +265,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); @@ -386,9 +488,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 }, @@ -486,6 +589,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; @@ -516,7 +622,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/path/src/lib.rs b/crates/path/src/lib.rs index b4216e88..169a7428 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 00000000..42973803 --- /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/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 00000000..93d082b5 --- /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 00000000..a1a9d0b3 --- /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 00000000..ca063dee --- /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 00000000..b35e9f39 --- /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 00000000..cd835113 --- /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 00000000..e24f6ec8 --- /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 00000000..9a67f85c --- /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 00000000..b9b0bff4 --- /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 00000000..b4e91f2b --- /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 00000000..9e54bdc3 --- /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 bf8fcc2a..887bcc70 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -175,6 +175,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, /// Color Engine (Adobe-style built-in or OCIO with ACES working spaces). #[serde(default)] pub color_engine: ColorEngine, @@ -198,6 +201,7 @@ impl Default for ProjectSettings { frame_start: 0, audio_sample_rate: 48_000, gpu_acceleration: true, + comment: String::new(), color_engine: ColorEngine::Adobe, hdr: HdrMode::Clip, output_space: None, @@ -827,9 +831,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, @@ -837,6 +841,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 { @@ -853,6 +861,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/psd/README.md b/crates/psd/README.md index e238b9b2..b0b19ba3 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 885ab3c2..61cadc7d 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 68680fe7..cd2f9293 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 fb78cc78..160e81d3 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![]; diff --git a/crates/raster/src/cuts.rs b/crates/raster/src/cuts.rs new file mode 100644 index 00000000..28ca93f3 --- /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 00000000..a4ae40ce --- /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 4f244199..f3e64695 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 00000000..6b803a12 --- /dev/null +++ b/crates/raster/src/scopes.rs @@ -0,0 +1,555 @@ +//! 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) 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. + +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) { + // 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| 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`. + 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); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index aa990f0e..6ede490b 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 @@ -66,7 +69,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`]). @@ -82,6 +85,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 +246,46 @@ 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 +/// 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)] @@ -260,6 +306,19 @@ pub struct RenderOpts { pub roi: Option<[f64; 4]>, /// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use). pub proxy: effectcraft_project::render_queue::ProxyUse, + /// 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 { @@ -272,6 +331,8 @@ impl Default for RenderOpts { view: None, roi: None, backend: Backend::Cpu, + nested_switches: true, + draft_shadows: true, proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings, } } @@ -299,6 +360,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`). @@ -342,9 +406,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 } @@ -494,11 +588,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) { @@ -601,19 +692,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] }; @@ -644,7 +744,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) @@ -660,8 +760,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))); @@ -676,14 +775,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). @@ -719,7 +820,7 @@ impl<'a> Renderer<'a> { // values. let ov = essential_overrides(ctx, layer); let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) }; - let base = self.nested(); + let base = self.nested_through(layer); let sub = match &tmp { Some(p) => Renderer { project: p, ..base }, None => base, @@ -1008,7 +1109,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 { @@ -1053,7 +1154,7 @@ impl<'a> Renderer<'a> { /// Whether a layer is motion blurred. fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { - self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer) } /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D @@ -1177,7 +1278,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; } @@ -1403,6 +1504,29 @@ 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) + } + + /// 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); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 02c7f496..55373714 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/tests_collapse.rs b/crates/render/src/tests_collapse.rs index d3cf954c..0da0288f 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/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 2c3b03bc..ca124a41 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -855,7 +855,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: { 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 732aee68..25755a47 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. @@ -72,7 +74,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 +91,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; @@ -160,9 +161,16 @@ 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 { + 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 @@ -201,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); @@ -211,9 +219,10 @@ fn prepare_with<'a>( if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { buf = dof_blur(&cam, &dof, world, buf); } + let 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; @@ -223,7 +232,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() }; @@ -235,7 +244,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, @@ -364,12 +372,29 @@ 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); + let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| r.opts.shadows() && 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| r.quality(l) == 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)) -> 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 .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; @@ -400,11 +425,7 @@ 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(); - 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 @@ -624,6 +645,82 @@ 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; depth of field already applied). + pub buf: Arc, + 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, 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) { + return None; + } + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + return None; + } + let g = gather(r, ctx, run, out); + 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, + 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)| r.opts.shadows() && 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). @@ -829,7 +926,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); diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index 7faaa499..b880cdcd 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,6 +17,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; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index c4f256f4..fec9d9e3 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 61b49595..c494a6bf 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/svg/src/lib.rs b/crates/svg/src/lib.rs index 22b5fead..0536480c 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 2cfb3dd3..de82695b 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/text/src/fonts.rs b/crates/text/src/fonts.rs index c3a60f5d..c70fedc4 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. @@ -212,6 +214,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; @@ -300,6 +317,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/layout.rs b/crates/text/src/layout.rs index 824b7373..3333cdd1 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 26a659bd..beef0382 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/text/src/sfnt.rs b/crates/text/src/sfnt.rs index bb00d515..72e34506 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 3d1d627c..7e24adfe 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -51,11 +51,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 } } } @@ -165,7 +167,7 @@ pub struct AudioPlayback { stop: Arc, thread: Option>, /// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`]. - feeder: Option<(RenderSource, ItemId, i64)>, + feeder: Option<(RenderSource, ItemId, i64, u32)>, pub rate: u32, pub span: PlaySpan, } @@ -178,6 +180,9 @@ fn queue_target(rate: u32) -> usize { 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, @@ -186,8 +191,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)); @@ -195,10 +202,10 @@ 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); } if cfg!(target_arch = "wasm32") { - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor, mix)), rate, span }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -211,7 +218,7 @@ impl AudioPlayback { let target = queue_target(rate); 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)); } @@ -226,10 +233,10 @@ impl AudioPlayback { /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread /// does elsewhere), then let the device take what it needs. pub fn pump(&mut self) { - if let Some((src, comp, cursor)) = &mut self.feeder { + if let Some((src, comp, cursor, mix)) = &mut self.feeder { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate); + *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix); } } self.device.pump(); @@ -261,7 +268,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 { @@ -271,11 +278,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 91d7c335..316b4ea3 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), @@ -779,6 +797,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 44900918..3b222570 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 77dfc39f..f46f75e7 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; @@ -257,7 +258,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). @@ -477,6 +478,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, proxy: Default::default(), } } @@ -530,7 +533,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}"), } @@ -789,6 +793,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(); @@ -808,6 +817,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 18add8dc..d4bfed8a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -764,6 +764,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 @@ -804,6 +835,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"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), @@ -823,7 +856,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, @@ -883,6 +916,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), } } @@ -1147,29 +1189,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. @@ -1187,6 +1238,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 ef5070b3..42c3aacf 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/content_fill_panel.rs b/crates/ui-egui/src/panels/content_fill_panel.rs new file mode 100644 index 00000000..1937b4cc --- /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 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/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs new file mode 100644 index 00000000..1efd6876 --- /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/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs new file mode 100644 index 00000000..cfc69825 --- /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 39640ef6..20382a3e 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -397,6 +397,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 00000000..4f6fd0ae --- /dev/null +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -0,0 +1,368 @@ +//! 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})); + } + // 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 + 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; + 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 740ced10..a3179a8b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -3,13 +3,16 @@ 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; pub mod effects_presets; pub mod essential; pub mod expr_bar; +pub mod expr_editor; pub mod flowchart; +pub mod footage_panel; pub mod forms; pub mod fx_widgets; pub mod graph; @@ -20,8 +23,10 @@ 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; @@ -29,6 +34,7 @@ pub mod puppet_tool; pub mod render_queue; pub mod roto_tool; pub mod rq_templates; +pub mod scopes_panel; pub mod script_console; pub mod settings; pub mod shortcut_editor; @@ -58,6 +64,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, frames @@ -100,6 +108,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 00000000..cbf28c8a --- /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 ce750fac..f3898954 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 00000000..db5279d8 --- /dev/null +++ b/crates/ui-egui/src/panels/scopes_panel.rs @@ -0,0 +1,199 @@ +//! 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; + // 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 + top), 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/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fef958a7..81b9d077 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; @@ -98,12 +98,11 @@ 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)); + 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 +231,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 +276,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"), @@ -365,20 +468,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 { @@ -406,6 +518,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 10ad62e6..80c1a36c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1573,15 +1573,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)); @@ -1986,6 +1988,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/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 04999a90..ccb963cf 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -517,12 +517,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; @@ -532,7 +534,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; @@ -545,13 +547,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); } } } @@ -613,6 +617,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() { @@ -661,6 +687,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); @@ -1564,6 +1625,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() { @@ -1577,14 +1658,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); @@ -1594,12 +1678,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 5424ed83..4d3bd312 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -269,7 +269,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; @@ -285,6 +287,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 ff98f778..0ce1b29c 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); } } @@ -324,6 +324,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 }; @@ -400,6 +405,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: rect }; 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(); @@ -410,7 +416,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, dc), egui::TextureOptions::LINEAR); + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), 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 f9bf00db..ab3fbd79 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 00000000..9e730196 --- /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/state.rs b/crates/ui-egui/src/state.rs index adffbbeb..07715d16 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/src/theme.rs b/crates/ui-egui/src/theme.rs index c32ecb24..eb02bf48 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_panels.rs b/crates/ui-egui/tests/ui_panels.rs new file mode 100644 index 00000000..ee027daa --- /dev/null +++ b/crates/ui-egui/tests/ui_panels.rs @@ -0,0 +1,144 @@ +//! 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"); +} + +/// 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(); + } +} diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 2c943740..2c9af608 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/architecture.md b/docs/architecture.md index 0487cbd5..7c341818 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 | @@ -163,17 +164,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 @@ -273,6 +288,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/effects.md b/docs/effects.md index a7717d75..84735286 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 304 effects with 2446 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**: 25 effects also run on the GPU compositor with identical results. +- **GPU**: 59 effects also run on the GPU compositor with identical results. - **32**: 304 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 262 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 | @@ -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` | 7 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 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 @@ -131,13 +131,13 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Bezier Warp | `ec.distort.bezierwarp` | 13 | | 32 | Implemented | -| Bulge | `ec.distort.bulge` | 6 | | 32 | Implemented | +| Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented | | CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented | | CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | | CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | | CC Flo Motion | `ec.distort.ccflomotion` | 7 | | 32 | Implemented | | CC Griddler | `ec.distort.ccgriddler` | 5 | | 32 | Implemented | -| CC Lens | `ec.distort.cclens` | 3 | | 32 | Implemented | +| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | | CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented | | CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented | @@ -148,27 +148,27 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Tiler | `ec.distort.cctiler` | 3 | | 32 | Implemented | | Corner Pin | `ec.distort.cornerpin` | 4 | | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | -| Displacement Map | `ec.distort.displacementmap` | 8 | | 32 | Implemented | +| Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | | Liquify | `ec.distort.liquify` | 12 | | 32 | Implemented | | Magnify | `ec.distort.magnify` | 10 | | 32 | Implemented | -| Mesh Warp | `ec.distort.meshwarp` | 3 | | 32 | Implemented | +| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | | Offset | `ec.distort.offset` | 2 | | 32 | Implemented | | Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels | | Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points | -| Ripple | `ec.distort.ripple` | 7 | | 32 | Implemented | +| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | | Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur | -| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Partial: no locked pinning variants | -| Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented | +| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants | +| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | -| Wave Warp | `ec.distort.wavewarp` | 7 | | 32 | Implemented | +| Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | ## Expression Controls @@ -188,7 +188,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 | @@ -198,15 +198,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 | @@ -244,8 +244,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 | @@ -264,14 +264,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 | @@ -354,10 +354,10 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | | Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | -| Emboss | `ec.stylize.emboss` | 4 | | 32 | Implemented | -| Find Edges | `ec.stylize.findedges` | 2 | | 32 | Implemented | +| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | +| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map | -| Mosaic | `ec.stylize.mosaic` | 3 | | 32 | Implemented | +| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | | Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | | Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | | Roughen Edges | `ec.stylize.roughenedges` | 13 | | 32 | Implemented | @@ -402,11 +402,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 diff --git a/docs/parity.md b/docs/parity.md index 2b30c576..43d93b12 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -20,12 +20,9 @@ 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; -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; -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). +renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; +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; face tracking; and 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. @@ -112,19 +109,19 @@ 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 | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | 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 | -| 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) | +| 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) | | 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 | @@ -166,13 +163,32 @@ 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). 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). diff --git a/docs/preferences.md b/docs/preferences.md index 4e37704e..a1123e37 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -70,72 +70,80 @@ 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 +- `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management +- `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 -- `previews.displayProfile`: Display Color Space -- `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 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21928b90..ad904eba 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"),