Files
effectcraft/crates/effects/src/warp_stab.rs
Brandon ThomasandClaude Opus 5.5 a006ed5765 M13.32: web main-thread stall: Warp Stabilizer plans solved in job workers
warp.status solved the stabilization plan (Subspace Warp eigen-decompositions and mesh fits)
on the page the first time it saw an analysis: ~4.9 s blocking the browser's event loop in
the smoke test. The analysis job now sends the plan summary with its result, and a settings
change has a WarpPlan job solve it (status reports stabilizing meanwhile). Desktop unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:40:46 -04:00

619 lines
25 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Warp Stabilizer (Effect ▸ Distort ▸ Warp Stabilizer).
//!
//! The analysis (feature tracking and per-frame motion fits) runs as a background job in the
//! engine (`warp.analyze`) and is stored, as JSON, in the instance's hidden **Analysis**
//! parameter ([`effectcraft_track::stabilize::WarpAnalysis`]); **Analysis Key** records what
//! the analysis was made from (the layer's source, in/out points and time mapping) so edits that
//! change the frames invalidate it. Rendering derives the stabilization plan from the analysis
//! and the Stabilization / Borders / Advanced settings ([`effectcraft_track::stabilize::plan`],
//! cached per analysis and settings) and warps each frame by its corrective transform:
//!
//! - **Stabilize Only** shows the moving frame edges, **Stabilize, Crop** crops to the region
//! valid on every frame, **Stabilize, Crop, Auto-scale** scales that region back up to the
//! frame (up to Maximum Scale, minus the Action-safe Margin), Additional Scale scales on top;
//! - **Stabilize, Synthesize Edges** fills the uncovered borders from neighbouring frames
//! (within Synthesis Input Range), mapped through the analysed frame-to-frame motion and read
//! with [`crate::EffectHost::self_at`]; Synthesis Edge Feather blends the seam and Synthesis
//! Edge Cropping ignores bad edge pixels of the input frames;
//! - **Show Track Points** draws the analysed background features.
//!
//! *Subspace Warp* warps each frame with a content-preserving mesh fitted to the analysis'
//! subspace-smoothed feature trajectories ([`effectcraft_track::subspace`]); analyses made
//! before trajectories were stored fall back to a perspective warp. *Rolling Shutter Ripple*
//! picks the mesh: Automatic Reduction (12 × 8, stiffer) or Enhanced Reduction (twice the rows,
//! softer), which follows the row-wise wobble of rolling-shutter shake more closely.
//!
//! Without an analysis (or with one made for another layer size) the input passes through.
use std::collections::HashMap;
use std::hash::Hasher;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::Homography;
use effectcraft_track::stabilize::{Framing, Method, Plan, Ripple, StabResult, StabSettings, WarpAnalysis, plan};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
/// The effect's id.
pub const ID: &str = "ec.distort.warpstabilizer";
/// Hidden parameters: the stored analysis (JSON) and its key (see the module docs).
pub const ANALYSIS: &str = "analysis";
pub const ANALYSIS_KEY: &str = "analysisKey";
/// Display names of the instance's parameter groups (by match id).
pub const GROUPS: &[(&str, &str)] = &[
("stabilization", "Stabilization"),
("borders", "Borders"),
("autoScale", "Auto-scale"),
("advanced", "Advanced"),
("edgeCropping", "Synthesis Edge Cropping"),
];
pub const RESULTS: [&str; 2] = ["Smooth Motion", "No Motion"];
pub const METHODS: [&str; 4] = ["Position", "Position, Scale, Rotation", "Perspective", "Subspace Warp"];
pub const FRAMINGS: [&str; 4] = ["Stabilize Only", "Stabilize, Crop", "Stabilize, Crop, Auto-scale", "Stabilize, Synthesize Edges"];
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "Warp Stabilizer",
category: "Distort",
params: vec![
p("stabilization/result", "Result", Value::Enum(0), popup(&RESULTS)),
p("stabilization/smoothness", "Smoothness", num(50.0), slider(0.0, 1000.0, 0.0, 100.0, 0)),
p("stabilization/method", "Method", Value::Enum(3), popup(&METHODS)),
p("stabilization/preserveScale", "Preserve Scale", Value::Bool(false), ParamUi::Checkbox),
p("borders/framing", "Framing", Value::Enum(2), popup(&FRAMINGS)),
p("borders/autoScale/maximumScale", "Maximum Scale", num(150.0), slider(100.0, 1000.0, 100.0, 200.0, 0)),
p("borders/autoScale/actionSafeMargin", "Action-safe Margin", num(0.0), slider(0.0, 45.0, 0.0, 20.0, 0)),
p("borders/additionalScale", "Additional Scale", num(100.0), slider(1.0, 1000.0, 50.0, 150.0, 0)),
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
p("advanced/rollingShutterRipple", "Rolling Shutter Ripple", Value::Enum(0), popup(&["Automatic Reduction", "Enhanced Reduction"])),
p("advanced/cropLessSmoothMore", "Crop Less <-> Smooth More", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("advanced/synthesizeInputRange", "Synthesis Input Range (seconds)", num(1.0), slider(0.0, 10.0, 0.0, 5.0, 2)),
p("advanced/synthesizeEdgeFeather", "Synthesis Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("advanced/edgeCropping/left", "Left", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/top", "Top", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/right", "Right", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/bottom", "Bottom", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
p("advanced/showTrackPoints", "Show Track Points", Value::Bool(false), ParamUi::Checkbox),
p("advanced/trackPointSize", "Track Point Size", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 0)),
p(ANALYSIS, "Analysis", Value::Str(String::new()), ParamUi::Hidden),
p(ANALYSIS_KEY, "Analysis Key", Value::Str(String::new()), ParamUi::Hidden),
],
render,
gpu: false,
float: true,
}]
}
/// A parameter by its spec id (`stabilization/smoothness`), from either flattened instance
/// params (nested groups become `#n/` prefixes, see [`crate::flatten_params`]) or plain ids.
pub fn param<'a>(params: &'a Params, id: &str) -> Option<&'a Value> {
if let Some(v) = params.get(id) {
return Some(v);
}
let mut pre = String::new();
let segs: Vec<&str> = id.split('/').collect();
let (last, groups) = segs.split_last()?;
for g in groups {
pre = params.group(&pre, g)?;
}
params.get(&format!("{pre}{last}"))
}
fn f(params: &Params, id: &str) -> f64 {
param(params, id).map(Value::as_f64).unwrap_or(0.0)
}
fn e(params: &Params, id: &str) -> u32 {
param(params, id).map(Value::as_enum).unwrap_or(0)
}
fn b(params: &Params, id: &str) -> bool {
param(params, id).map(Value::as_bool).unwrap_or(false)
}
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
match param(params, id) {
Some(Value::Str(s)) => s,
_ => "",
}
}
/// Stabilization settings from the instance's parameters (`fps`: the analysis' frame rate).
pub fn settings(params: &Params, fps: f64) -> StabSettings {
StabSettings {
result: if e(params, "stabilization/result") == 1 { StabResult::NoMotion } else { StabResult::SmoothMotion },
smoothness: f(params, "stabilization/smoothness"),
method: match e(params, "stabilization/method") {
0 => Method::Position,
1 => Method::Similarity,
2 => Method::Perspective,
_ => Method::SubspaceWarp,
},
preserve_scale: b(params, "stabilization/preserveScale"),
framing: match e(params, "borders/framing") {
0 => Framing::StabilizeOnly,
1 => Framing::StabilizeCrop,
3 => Framing::SynthesizeEdges,
_ => Framing::StabilizeCropAutoScale,
},
max_scale: f(params, "borders/autoScale/maximumScale"),
action_safe: f(params, "borders/autoScale/actionSafeMargin"),
additional_scale: f(params, "borders/additionalScale"),
// AE's 0…100 slider (50 = balanced) onto the planner's −100…100.
crop_less_smooth_more: (f(params, "advanced/cropLessSmoothMore") - 50.0) * 2.0,
fps,
ripple: if e(params, "advanced/rollingShutterRipple") == 1 { Ripple::Enhanced } else { Ripple::Automatic },
}
}
fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
struct KeyHasher(u64);
impl Hasher for KeyHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.0 ^= *b as u64;
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
}
}
}
fn settings_key(s: &StabSettings) -> u64 {
let mut h = KeyHasher(0x9e37_79b9_7f4a_7c15);
for v in [s.smoothness, s.max_scale, s.action_safe, s.additional_scale, s.crop_less_smooth_more, s.fps] {
h.write_u64(v.to_bits());
}
h.write(format!("{:?}{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing, s.ripple).as_bytes());
h.finish()
}
type AnalysisCache = Mutex<HashMap<u64, Arc<WarpAnalysis>>>;
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
static C: OnceLock<AnalysisCache> = OnceLock::new();
let key = fnv(json.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some((key, a.clone()));
}
let a = Arc::new(WarpAnalysis::from_json(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8 {
m.clear();
}
m.insert(key, a.clone());
Some((key, a))
}
fn plans() -> &'static PlanCache {
static C: OnceLock<PlanCache> = OnceLock::new();
C.get_or_init(Default::default)
}
fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
let key = (akey, settings_key(s));
let c = plans();
if let Some(p) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return p.clone();
}
let p = Arc::new(plan(a, s));
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 32 {
m.clear();
}
m.insert(key, p.clone());
p
}
/// The stored analysis of an instance (`None` when not analysed).
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
let j = s(params, ANALYSIS);
if j.is_empty() {
return None;
}
analysis_cached(j).map(|(_, a)| a).filter(|a| !a.is_empty())
}
/// The stabilization of an instance at layer time `time`: (frame index, plan).
pub fn plan_at(params: &Params, time: f64) -> Option<(usize, Arc<WarpAnalysis>, Arc<Plan>)> {
let j = s(params, ANALYSIS);
if j.is_empty() {
return None;
}
let (key, a) = analysis_cached(j)?;
let k = a.frame_at(time)?;
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
let pl = plan_cached(key, &a, &settings(params, fps));
(k < pl.warps.len()).then_some((k, a, pl))
}
/// What `warp.status` reports of a stabilization plan (no meshes): small, so another engine
/// instance (a browser job worker) can compute it and send it over.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanSummary {
/// [`Plan::warps`] (row-major 3 × 3 per frame).
pub warps: Vec<[[f64; 3]; 3]>,
pub auto_scale: f64,
pub crop: Option<[f64; 4]>,
pub valid_fraction: f64,
}
impl PlanSummary {
fn of(p: &Plan) -> PlanSummary {
PlanSummary { warps: p.warps.iter().map(|h| h.0).collect(), auto_scale: p.auto_scale, crop: p.crop, valid_fraction: p.valid_fraction }
}
}
type SummaryCache = Mutex<HashMap<(u64, u64), Arc<PlanSummary>>>;
fn summaries() -> &'static SummaryCache {
static C: OnceLock<SummaryCache> = OnceLock::new();
C.get_or_init(Default::default)
}
/// The key of an instance's plan (analysis, settings), with the analysis (`None` when not
/// analysed). Cheap once the analysis is parsed: no plan is computed.
fn plan_key(params: &Params) -> Option<((u64, u64), Arc<WarpAnalysis>, StabSettings)> {
let j = s(params, ANALYSIS);
if j.is_empty() {
return None;
}
let (akey, a) = analysis_cached(j)?;
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
let st = settings(params, fps);
Some(((akey, settings_key(&st)), a, st))
}
/// The key a [`PlanSummary`] of this instance is stored under ([`store_summary`]).
pub fn summary_key(params: &Params) -> Option<[u64; 2]> {
plan_key(params).map(|(k, _, _)| [k.0, k.1])
}
/// Keep a summary computed elsewhere (a job worker), so [`cached_summary_at`] finds it.
pub fn store_summary(key: [u64; 2], summary: PlanSummary) {
keep_summary((key[0], key[1]), Arc::new(summary));
}
fn keep_summary(key: (u64, u64), summary: Arc<PlanSummary>) {
let mut m = summaries().lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 32 {
m.clear();
}
m.insert(key, summary);
}
/// The plan summary of an instance (its key and the summary), computing the plan when it isn't
/// cached (seconds for a long Subspace Warp clip). `None` when not analysed.
pub fn summary(params: &Params) -> Option<([u64; 2], Arc<PlanSummary>)> {
let (key, a, st) = plan_key(params)?;
let cached = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let sum = match cached {
Some(s) => s,
None => {
let s = Arc::new(PlanSummary::of(&plan_cached(key.0, &a, &st)));
keep_summary(key, s.clone());
s
}
};
Some(([key.0, key.1], sum))
}
/// The plan summary of an instance at layer time `time`: (frame index, summary). Computes the
/// plan when it isn't cached ([`summary`]).
pub fn summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>)> {
if let Some(r) = cached_summary_at(params, time) {
return Some(r);
}
let (_, sum) = summary(params)?;
let k = analysis(params)?.frame_at(time)?;
(k < sum.warps.len()).then_some((k, sum))
}
/// [`summary_at`] without computing anything: from a stored summary or a cached plan; `None`
/// when neither exists yet (or the instance is not analysed).
pub fn cached_summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>)> {
let (key, a, _) = plan_key(params)?;
let k = a.frame_at(time)?;
let sum = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let sum = match sum {
Some(s) => s,
None => {
let p = plans().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned()?;
let s = Arc::new(PlanSummary::of(&p));
keep_summary(key, s.clone());
s
}
};
(k < sum.warps.len()).then_some((k, sum))
}
/// Motion from frame `k` to frame `j` (scene points of frame k → frame j), from the analysed
/// frame-to-frame homographies.
pub fn motion_between(a: &WarpAnalysis, k: usize, j: usize) -> Homography {
let mut m = Homography::IDENTITY;
if j > k {
for i in k + 1..=j {
m = a.frames[i].motion(Method::Perspective).then_after(&m);
}
} else {
for i in (j + 1..=k).rev() {
let inv = a.frames[i].motion(Method::Perspective).inverse().unwrap_or(Homography::IDENTITY);
m = inv.then_after(&m);
}
}
m
}
fn inside(r: &[f64; 4], p: [f64; 2]) -> bool {
p[0] >= r[0] && p[0] <= r[2] && p[1] >= r[1] && p[1] <= r[3]
}
/// Distance from `p` to the nearest edge of `r` (positive inside).
fn inset(r: &[f64; 4], p: [f64; 2]) -> f64 {
(p[0] - r[0]).min(r[2] - p[0]).min(p[1] - r[1]).min(r[3] - p[1])
}
fn lerp_px(a: Px, b: Px, t: f32) -> Px {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
}
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let Some((k, a, pl)) = plan_at(ctx.params, ctx.time) else { return buf };
let size = a.size;
if (size[0] - ctx.layer_size[0]).abs() > 0.5 || (size[1] - ctx.layer_size[1]).abs() > 0.5 || ctx.adjustment {
return buf;
}
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let Some(inv) = pl.warps[k].inverse() else { return buf };
// Output point → source point of this frame (a mesh lookup for Subspace Warp).
let mesh = pl.meshes.get(k);
let finv = pl.framing.inverse().unwrap_or(Homography::IDENTITY);
let src_of = |p: [f64; 2]| match mesh {
Some(m) => m.sample(finv.apply(p)),
None => inv.apply(p),
};
let (ow, oh) = (((size[0] * sc).ceil() as u32).max(1), ((size[1] * sc).ceil() as u32).max(1));
let synth = matches!(settings(ctx.params, 30.0).framing, Framing::SynthesizeEdges);
let crop = [
f(ctx.params, "advanced/edgeCropping/left").max(0.0),
f(ctx.params, "advanced/edgeCropping/top").max(0.0),
size[0] - f(ctx.params, "advanced/edgeCropping/right").max(0.0),
size[1] - f(ctx.params, "advanced/edgeCropping/bottom").max(0.0),
];
let valid = if synth { crop } else { [0.0, 0.0, size[0], size[1]] };
let feather = if synth { f(ctx.params, "advanced/synthesizeEdgeFeather").max(0.0) } else { 0.0 };
let src = &buf;
// Inside the frame, edge pixels extend to the frame's edge; outside it is transparent.
let sample = |b: &Buf, q: [f64; 2]| {
if q[0] < 0.0 || q[1] < 0.0 || q[0] > size[0] || q[1] > size[1] {
return [0.0; 4];
}
b.img.sample_bilinear_clamped(q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1])
};
let mut out = Image::new(ow, oh);
// Source position (frame k layer pixels) of every output pixel, and how much of the pixel
// still needs synthesizing (0 = none).
let need_m = Mutex::new(if synth { vec![0.0f32; (ow * oh) as usize] } else { vec![] });
out.rows_mut().for_each(|(y, row)| {
let mut local = vec![];
for (x, px) in row.iter_mut().enumerate() {
let p = [(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc];
if let Some(c) = &pl.crop
&& !inside(c, p)
{
continue;
}
let q = src_of(p);
if !synth {
*px = sample(src, q);
continue;
}
let d = inset(&valid, q);
if d >= feather.max(0.5) {
*px = sample(src, q);
} else {
let w = if d <= 0.0 {
1.0
} else if feather > 0.0 {
(1.0 - d / feather) as f32
} else {
1.0
};
if d > 0.0 {
*px = sample(src, q);
}
local.push((x, w));
}
}
if !local.is_empty() {
let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner());
for (x, w) in local {
n[y * ow as usize + x] = w;
}
}
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
for q in &a.frames[k].points {
let Some(o) = pl.output_of(k, [q[0] as f64, q[1] as f64]) else { continue };
dot(&mut out, [o[0] * sc, o[1] * sc], r, [0.25, 0.75, 1.0, 1.0]);
}
}
Buf { img: out, offset: [0.0; 2], scale: sc }
}
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(
ctx: &EffectCtx,
a: &WarpAnalysis,
k: usize,
src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &[f64; 4],
sc: f64,
out: &mut Image,
need: &[f32],
) {
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
let ow = out.width as usize;
let mut filled = vec![false; need.len()];
let todo: Vec<usize> = (0..need.len()).filter(|i| need[*i] > 0.0).collect();
let mut left = todo.len();
for d in 1..=range {
if left == 0 {
break;
}
for j in [k as i64 + d as i64, k as i64 - d as i64] {
if j < 0 || j as usize >= n || left == 0 {
continue;
}
let j = j as usize;
let Some(nb) = host.self_at(a.start + j as f64 * a.frame_duration, ctx.env.effect_index) else { continue };
let m = motion_between(a, k, j);
let hits: Vec<(usize, Px)> = todo
.par_iter()
.filter(|i| !filled[**i])
.filter_map(|i| {
let (x, y) = (i % ow, i / ow);
let q = src_of([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let qj = m.apply(q);
inside(crop, qj).then(|| (*i, nb.img.sample_bilinear_clamped(qj[0] * nb.scale + nb.offset[0], qj[1] * nb.scale + nb.offset[1])))
})
.collect();
for (i, px) in hits {
filled[i] = true;
left -= 1;
out.data[i] = lerp_px(out.data[i], px, need[i]);
}
}
}
}
fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
let (x0, x1) = ((c[0] - r - 1.0).floor().max(0.0) as i64, (c[0] + r + 1.0).ceil() as i64);
let (y0, y1) = ((c[1] - r - 1.0).floor().max(0.0) as i64, (c[1] + r + 1.0).ceil() as i64);
for y in y0..y1.min(img.height as i64) {
for x in x0..x1.min(img.width as i64) {
let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]) - r).clamp(-0.5, 0.5);
let cov = (0.5 - d) as f32;
if cov > 0.0 {
let i = img.idx(x as u32, y as u32);
img.data[i] = lerp_px(img.data[i], col, cov);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EffectEnv;
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![], tracks: vec![] };
for k in 0..n {
let mut fm = FrameMotion::default();
if k > 0 {
let (p, q) = (shift(k - 1), shift(k));
let d = [q[0] - p[0], q[1] - p[1]];
fm.t = d;
fm.s = [1.0, 0.0, d[0], d[1]];
fm.h = [1.0, 0.0, d[0], 0.0, 1.0, d[1], 0.0, 0.0];
}
a.frames.push(fm);
}
a.to_json()
}
fn params(json: &str, framing: u32, result: u32) -> Params {
let s = &specs()[0];
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
p.values.insert(ANALYSIS.into(), Value::Str(json.into()));
p.values.insert("borders/framing".into(), Value::Enum(framing));
p.values.insert("stabilization/result".into(), Value::Enum(result));
p
}
/// A white dot at `c` on black.
fn frame(c: [f64; 2]) -> Image {
let mut im = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
dot(&mut im, c, 3.0, [1.0; 4]);
im
}
fn centroid(im: &Image) -> [f64; 2] {
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for y in 0..im.height {
for x in 0..im.width {
let v = im.get(x as i64, y as i64)[0] as f64;
sx += v * (x as f64 + 0.5);
sy += v * (y as f64 + 0.5);
sw += v;
}
}
[sx / sw, sy / sw]
}
#[test]
fn no_motion_locks_the_scene_and_unanalysed_passes_through() {
let shift = |k: usize| [((k * 7) % 5) as f64 - 2.0, ((k * 3) % 4) as f64 - 1.5];
let json = analysis_json(10, 64.0, 48.0, shift);
let ps = params(&json, 0, 1);
let mut cs = vec![];
for k in 0..10 {
let d = shift(k);
let img = frame([32.0 + d[0], 24.0 + d[1]]);
let ctx = EffectCtx { params: &ps, time: k as f64 / 25.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let out = render(&ctx, Buf { img, offset: [0.0; 2], scale: 1.0 });
cs.push(centroid(&out.img));
}
for c in &cs {
assert!((c[0] - cs[0][0]).abs() < 0.05 && (c[1] - cs[0][1]).abs() < 0.05, "{cs:?}");
}
// Not analysed: untouched.
let ps = params("", 0, 1);
let img = frame([10.0, 10.0]);
let ctx = EffectCtx { params: &ps, time: 0.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
}
#[test]
fn nested_param_lookup() {
let mut p = Params::default();
p.values.insert("#2/@match".into(), Value::Str("borders".into()));
p.values.insert("#1/@match".into(), Value::Str("stabilization".into()));
p.values.insert("#2/#1/@match".into(), Value::Str("autoScale".into()));
p.values.insert("#2/#1/maximumScale".into(), num(120.0));
p.values.insert("#1/smoothness".into(), num(12.0));
assert_eq!(f(&p, "borders/autoScale/maximumScale"), 120.0);
assert_eq!(f(&p, "stabilization/smoothness"), 12.0);
}
}