diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7e968297..3ad4d6de 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -105,7 +105,7 @@ pub use noise::remove_grain_level; pub use noise3::{FractalGpu, fractal_gpu}; pub use stylize3::{BumpLight, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; -pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan}; +pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 67d35378..d455ce03 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) { /// The motion field from frame `a` to frame `b` (or from the warp layer's frames). pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow { + timewarp_flow_search(a, b, pm, 4) +} + +/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs +/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur +/// must find fast full-frame displacements (#535). +fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow { let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0); let gain = if pm.correct_luminance { let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0))); @@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow { 1.0 }; let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain); - let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4); + let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius); smooth_flow(&mut flow, pm); flow } @@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf { mean_of(ctx, b, ×) } -fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { +fn shutter_times(ctx: &EffectCtx) -> Vec { let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); - let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); - mean_of(ctx, b, ×) + (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect() +} + +fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { + match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) { + Some(blur) => blur, + None => mean_of(ctx, b, &shutter_times(ctx)), + } +} + +/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two +/// source frames it falls between along a measured motion field (the same synthesis Timewarp +/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion +/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring +/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect +/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's. +pub fn pmb_plan(ctx: &EffectCtx) -> Option { + ctx.env.host?; + let fps = ctx.fps(); + let samples = shutter_times(ctx); + let n = samples.len(); + let mut times = Vec::new(); + for &s in &samples { + let (f0, _) = tw_split(s, fps); + times.push(f0 / fps); + times.push((f0 + 1.0) / fps); + } + let fr = fetch(ctx, ×)?; + let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + let mut pm = PixelMotion::from(ctx, fr.grid.scale); + // PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px. + let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0); + pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32; + let mut flows: Vec = vec![]; + let mut steps = vec![]; + let w = 1.0 / n as f32; + for &s in &samples { + let (f0, frac) = tw_split(s, fps); + let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps)); + let step = if frac < 1e-4 { + TwStep::Whole(i0) + } else { + let flow = match flows.iter().position(|fl| fl.key == f0 as i64) { + Some(p) => p, + None => { + let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64); + flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) }); + flows.len() - 1 + } + }; + TwStep::Motion { a: i0, b: i1, w: frac, flow } + }; + steps.push((step, w)); + } + Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm }) +} + +/// A measured flow with the source frame pair it came from (plans cache one per pair). +struct FlowEntry { + key: i64, + flow: Flow, } #[cfg(test)] diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs index 564ba6fd..e7fd628a 100644 --- a/crates/gpu/src/fx_time.rs +++ b/crates/gpu/src/fx_time.rs @@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option>()) } - "ec.time.pixelmotionblur" => { - let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; - let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; - let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); - let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); - mean_of(e, ctx, b, ×) - } + // Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that + // cannot be built (no host) declines to the CPU effect. + "ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) { + Some(plan) => render_plan(e, &plan), + None => None, + }, "ec.time.timewarp" => timewarp(e, ctx, b), _ => None, } @@ -179,6 +178,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { return Some(b); } let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) }; + render_plan(e, &plan) +} + +/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`): +/// every step to an image on the GPU, then the weighted average. +fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option { // Pixel Motion's in-between frame of frames a, b along motion field `flow`. let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option { let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?; diff --git a/crates/render/tests/motion_blur_probe.rs b/crates/render/tests/motion_blur_probe.rs new file mode 100644 index 00000000..df1e0cf7 --- /dev/null +++ b/crates/render/tests/motion_blur_probe.rs @@ -0,0 +1,112 @@ +//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames +//! (#535). Two bars move in opposite directions between discrete source frames; the gap +//! pixels between their old and new positions must fill in, and Vector Detail must matter. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project}; +use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; +use std::sync::Arc; + +// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source +// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find +// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is +// undefined (the aperture problem) and which no synthesizer can invent coverage for. +struct Clip; +impl FootageSource for Clip { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { + let k = f.frame_rate.frame_at(t).clamp(0, 19); + let dx = 12 * (k - 10); + let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]); + for y in 0..48 { + for x in 0..64 { + if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) { + let g = (x - (20 + dx)) as f32 / 12.0; + image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]); + } + } + } + Some(Arc::new(image)) + } +} + +fn scene(detail: Option) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = Footage { + path: "generated.mov".into(), + kind: FootageKind::Video, + width: 64, + height: 48, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_25, + duration: Tick::from_seconds_f64(0.8), + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + missing: false, + ..Default::default() + }; + let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage)); + let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None); + layer.switches.frame_blend = FrameBlend::Off; + layer.switches.motion_blur = false; + if let Some(detail) = detail { + let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap(); + let mut next = p.next_id; + let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]); + for (name, value) in [ + ("shutterControl", Value::Enum(1)), + ("shutterAngle", Value::Scalar(180.0)), + ("shutterSamples", Value::Scalar(16.0)), + ("vectorDetail", Value::Scalar(detail)), + ] { + effect.prop_mut(name).unwrap().value = value; + } + p.next_id = next; + layer.props.sub_mut("effects").unwrap().children.push(effect.into()); + } + p.comp_mut(cid).unwrap().layers.push(layer); + (p, cid) +} + +fn render(p: &Project, cid: ItemId, time: f64) -> Image { + Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time)) +} + +fn difference(a: &Image, b: &Image) -> f64 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum() +} + +#[test] +fn pixel_motion_blur_synthesizes_from_motion_vectors() { + let (plain, cid) = scene(None); + let still = render(&plain, cid, 0.4); + let average = average_of(&plain, cid); + let (low, low_id) = scene(Some(0.0)); + let (high, high_id) = scene(Some(100.0)); + let a = render(&low, low_id, 0.4); + let b = render(&high, high_id, 0.4); + assert!(difference(&b, &still) > 1.0, "the blur equals the still frame"); + assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions"); + assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::>()); + assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it"); +} + +fn average_of(p: &Project, cid: ItemId) -> Image { + let mut average = Image::new(64, 48); + for i in 0..16 { + let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6; + let frame = render(p, cid, time); + for (p, q) in average.data.iter_mut().zip(&frame.data) { + for c in 0..4 { + p[c] += q[c] / 16.0; + } + } + } + average +}