From bff5b723ee734786a127f4592291dfe0caf5881c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 11:14:59 -0400 Subject: [PATCH] M5.10: Premiere 26 video transitions (84 in 10 folders + 21 Legacy) - project: vtransition.rs registry. Animation, Dissolve, Grunge & Distort, Immersive Video, Lights & Blurs, Slide, Smart Tools, Text, Transformers and Wipe folders, plus Legacy > Video Transitions. Old ids are kept: modern versions reuse push/slide/split/whip/clock_wipe/radial_wipe/ cross_zoom/page_peel, the old behaviour lives under *_legacy ids, and removed transitions move to a hidden Obsolete folder. Parameters are not animatable; find_transition looks up by kind (Mosaic/Mirror/ Directional Blur names are shared with video effects). - render: transitions/ module with procedural CPU implementations, a field-wipe engine (feather, border width/colour, anti-aliasing, centre), a 3D card renderer (flips, cube, folds, spins), page curl, blur/light/ grunge families and flat VR approximations. Endpoints are exact, pixel params scale with the render resolution, and Reverse now plays B->A backwards. There is no GPU transition path, so the plan keeps only the symmetric dissolves on the GPU. - engine: applyVideoTransition takes params/reverse; new sequence.setTransition; effects.list adds folder/kind filters, badges and detail paramInfo; sequence.inspect shows transition params. - ui: the Effects panel shows the Legacy top folder and all badges; ui.set effectsSearch. - tests: per-transition endpoint, continuity, midpoint, determinism, NaN, parameter-wiring and direction properties; golden fingerprints; tree counts; apply-every-transition via command; UI panel test. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 149 +++- crates/engine/src/mixer.rs | 3 + crates/engine/src/tests.rs | 84 +++ crates/project/src/effect.rs | 68 +- crates/project/src/lib.rs | 1 + crates/project/src/vtransition.rs | 446 +++++++++++ crates/render/src/lib.rs | 13 +- crates/render/src/plan.rs | 8 +- crates/render/src/tests.rs | 11 + crates/render/src/transitions/cards.rs | 103 +++ crates/render/src/transitions/dissolve.rs | 99 +++ crates/render/src/transitions/goldens.txt | 336 +++++++++ crates/render/src/transitions/grunge.rs | 220 ++++++ .../{transitions.rs => transitions/legacy.rs} | 105 ++- crates/render/src/transitions/lights.rs | 266 +++++++ crates/render/src/transitions/mod.rs | 411 ++++++++++ crates/render/src/transitions/motion.rs | 701 ++++++++++++++++++ crates/render/src/transitions/special.rs | 185 +++++ crates/render/src/transitions/tests.rs | 364 +++++++++ crates/render/src/transitions/wipe.rs | 272 +++++++ crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/panels/effects.rs | 6 +- crates/ui-egui/tests/scripted.rs | 43 ++ docs/contributing.md | 17 +- docs/testing.md | 9 + 25 files changed, 3793 insertions(+), 130 deletions(-) create mode 100644 crates/project/src/vtransition.rs create mode 100644 crates/render/src/transitions/cards.rs create mode 100644 crates/render/src/transitions/dissolve.rs create mode 100644 crates/render/src/transitions/goldens.txt create mode 100644 crates/render/src/transitions/grunge.rs rename crates/render/src/{transitions.rs => transitions/legacy.rs} (76%) create mode 100644 crates/render/src/transitions/lights.rs create mode 100644 crates/render/src/transitions/mod.rs create mode 100644 crates/render/src/transitions/motion.rs create mode 100644 crates/render/src/transitions/special.rs create mode 100644 crates/render/src/transitions/tests.rs create mode 100644 crates/render/src/transitions/wipe.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9db12448..079d4580 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1048,7 +1048,7 @@ fn build() -> Vec { "Apply Video Transition", ["Sequence"], Some("Cmd+D"), - r#"{"clip":id?,"effect":str?,"frames":i64?}"#, + r#"{"clip":id?,"effect":"cross_dissolve|Cross Dissolve|…"?,"frames":i64?,"edge":"in"|"out"?,"params":{param:value}?,"reverse":bool?}"#, has_seq, |s, p| apply_transition(s, p, TrackKind::Video) ), @@ -1061,6 +1061,15 @@ fn build() -> Vec { has_seq, |s, p| apply_transition(s, p, TrackKind::Audio) ), + cmd!( + "sequence.setTransition", + "Edit Transition Settings", + [], + None, + r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?}"#, + has_seq, + set_transition + ), cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| { let tr = track_p(s, p, "track").ok_or_else(|| bad("sequence.closeGap", "need `track`"))?; let t = time_p(s, p, "").unwrap_or(s.playhead()); @@ -1995,9 +2004,12 @@ fn build() -> Vec { Ok(inspect_sequence(s, id, q)) }), query!("state.inspect", "Inspect Editor State", "{}", |s, _| Ok(serde_json::to_value(&s.state).unwrap_or_default())), - query!("effects.list", "List Effects", "{}", |_, _| { - Ok(Value::Array(filmcraft_project::effect_defs().iter().map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "category": d.category, "params": d.params.iter().map(|p| p.id).collect::>()})).collect())) - }), + query!( + "effects.list", + "List Effects", + r#"{"kind":"Video"|"Audio"|"VideoTransition"|"AudioTransition"?,"folder":"Video Transitions/Wipe"?,"detail":bool?}"#, + |_, p| Ok(list_effects(p)) + ), query!("history.list", "List History", "{}", |s, _| Ok( json!({"undo": s.history.undo.iter().map(|h| &h.0).collect::>(), "redo": s.history.redo.iter().map(|h| &h.0).collect::>()}) )), @@ -2356,11 +2368,133 @@ fn nest(s: &mut Session, p: &Value) -> Result { .map(|n| json!({"sequence": n.0})) } +/// `effects.list`: every effect definition with its Effects-panel folder (optionally filtered by +/// kind or folder prefix); `detail` adds each parameter's label, type, default and choices. +fn list_effects(p: &Value) -> Value { + let kind = str_p(p, "kind"); + let folder = str_p(p, "folder"); + let detail = p.get("detail").and_then(Value::as_bool).unwrap_or(false); + let out = filmcraft_project::effect_defs() + .iter() + .filter(|d| kind.is_none_or(|k| format!("{:?}", d.kind).eq_ignore_ascii_case(k))) + .filter(|d| folder.is_none_or(|f| d.category.join("/").starts_with(f))) + .map(|d| { + let mut v = json!({ + "id": d.id, + "name": d.name, + "kind": format!("{:?}", d.kind), + "category": d.category, + "folder": d.category.join("/"), + "params": d.params.iter().map(|p| p.id).collect::>(), + "badges": {"accelerated": d.accelerated, "float32": d.float32, "yuv": d.yuv}, + }); + if detail { + v["paramInfo"] = Value::Array(d.params.iter().map(param_info).collect()); + } + v + }) + .collect(); + Value::Array(out) +} + +fn param_info(p: &filmcraft_project::ParamDef) -> Value { + use filmcraft_project::ParamKind as K; + let mut j = json!({"id": p.id, "label": p.label, "animatable": p.animatable, "default": serde_json::to_value(&p.default).unwrap_or_default()}); + let ty = match &p.kind { + K::Choice(opts) => { + j["options"] = json!(opts); + "choice" + } + K::Float { min, max, unit, .. } => { + j["min"] = json!(min); + j["max"] = json!(max); + j["unit"] = json!(unit); + "float" + } + K::Point => "point", + K::Color => "color", + K::Bool => "bool", + K::Angle => "angle", + K::Text => "text", + K::Curve { .. } => "curve", + K::Wheel => "wheel", + K::Path => "path", + }; + j["type"] = json!(ty); + j +} + +/// Set transition parameters from a JSON object (`{"direction": 1}` or `{"direction": "From East"}`, +/// colours as `"#rrggbb"` or `[r,g,b]`, points as `[x,y]`). Floats are clamped to their range. +fn set_transition_params(e: &mut filmcraft_project::EffectInstance, params: &Value, cmd: &str) -> Result<()> { + use filmcraft_project::{ParamKind as K, ParamValue as V}; + let Some(obj) = params.as_object() else { return Err(bad(cmd, "`params` must be an object")) }; + let def = e.def().ok_or_else(|| bad(cmd, "unknown transition"))?; + for (k, v) in obj { + let pd = def.param(k).ok_or_else(|| bad(cmd, format!("`{}` has no param `{k}`", def.name)))?; + let val = match (&pd.kind, v) { + (K::Choice(opts), Value::String(sv)) => { + V::Choice(opts.iter().position(|o| o.eq_ignore_ascii_case(sv)).ok_or_else(|| bad(cmd, format!("`{k}` must be one of {opts:?}")))? as u32) + } + _ => json_to_param(&pd.default, v).ok_or_else(|| bad(cmd, format!("`{k}`: value has the wrong type")))?, + }; + let val = match (&pd.kind, val) { + (K::Choice(opts), V::Choice(c)) if c as usize >= opts.len() => return Err(bad(cmd, format!("`{k}` must be < {}", opts.len()))), + (K::Float { min, max, .. }, V::Float(f)) => V::Float(f.clamp(*min, *max)), + (_, v) => v, + }; + match e.param_mut(k) { + Some(prm) => prm.value = val, + None => { + e.params.insert(k.clone(), filmcraft_project::Param::new(val)); + } + } + } + Ok(()) +} + +/// `sequence.setTransition`: edit an applied transition's settings (Effect Controls). +fn set_transition(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "sequence.setTransition"; + let id = p.get("transition").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "need `transition`"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let found = q.video_tracks.iter().chain(q.audio_tracks.iter()).find_map(|tr| tr.transitions.iter().find(|x| x.id.0 == id)).cloned(); + let mut cur = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?; + if p.get("reset").and_then(Value::as_bool).unwrap_or(false) + && let Some(def) = cur.effect.def() + { + cur.effect = def.instance(); + } + if let Some(params) = p.get("params") { + set_transition_params(&mut cur.effect, params, CMD)?; + } + if let Some(r) = p.get("reverse").and_then(Value::as_bool) { + cur.reverse = r; + } + let name = cur.effect.def().map_or("Transition", |d| d.name).to_string(); + s.edit_sequence(&format!("Edit {name}"), |q, _, _| { + for tr in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + if let Some(x) = tr.transitions.iter_mut().find(|x| x.id.0 == id) { + x.effect = cur.effect.clone(); + x.reverse = cur.reverse; + } + } + Ok(()) + })?; + Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse})) +} + fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result { let eff_id = str_p(p, "effect") .map(str::to_string) .unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() }); - let def = filmcraft_project::effect::find_effect_by_name(&eff_id).ok_or_else(|| bad("transition", format!("unknown transition `{eff_id}`")))?; + let ekind = if kind == TrackKind::Video { filmcraft_project::EffectKind::VideoTransition } else { filmcraft_project::EffectKind::AudioTransition }; + let def = filmcraft_project::vtransition::find_transition(&eff_id, ekind).ok_or_else(|| bad("transition", format!("unknown transition `{eff_id}`")))?; + let mut instance = def.instance(); + if let Some(params) = p.get("params") { + set_transition_params(&mut instance, params, "transition")?; + } + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let rate = s.sequence_rate(); let frames = p.get("frames").and_then(Value::as_i64).unwrap_or(s.project.settings.default_transition_duration_frames); let dur = rate.tick_of(frames); @@ -2404,8 +2538,7 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result>()})).collect::>(), })).collect::>(), - "transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0)})).collect::>(), + "transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::>()})).collect::>(), }) }; json!({ diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs index 5f3781ef..ec562deb 100644 --- a/crates/engine/src/mixer.rs +++ b/crates/engine/src/mixer.rs @@ -971,7 +971,10 @@ pub fn audio_gain(s: &mut Session, p: &Value) -> Result { /// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind). fn set_default_transition(s: &mut Session, p: &Value) -> Result { let id = str_p(p, "effect").ok_or_else(|| bad("effects.setDefaultTransition", "need `effect`"))?; + use filmcraft_project::vtransition::find_transition; let def = find_effect(id) + .or_else(|| find_transition(id, filmcraft_project::EffectKind::VideoTransition)) + .or_else(|| find_transition(id, filmcraft_project::EffectKind::AudioTransition)) .or_else(|| filmcraft_project::effect_defs().iter().find(|d| d.name.eq_ignore_ascii_case(id))) .ok_or_else(|| bad("effects.setDefaultTransition", format!("no effect `{id}`")))?; match def.kind { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72c97350..14b17847 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -112,6 +112,90 @@ fn transitions_and_markers() { assert_eq!(s.active_sequence().unwrap().markers.len(), n + 1); } +#[test] +fn every_video_transition_applies_via_command() { + let mut s = demo(); + let cut = s.active_sequence().unwrap().video_tracks[0].items[2].start; + s.execute("playhead.set", json!({"time": cut.0})).unwrap(); + let defs: Vec<_> = + filmcraft_project::effect_defs().iter().filter(|d| d.kind == filmcraft_project::EffectKind::VideoTransition && d.category[0] != "Obsolete").collect(); + assert_eq!(defs.len(), 84 + 21); + for (i, d) in defs.iter().enumerate() { + // alternate id and display-name lookups + let name = if i % 2 == 0 { d.id } else { d.name }; + let r = s.execute("sequence.applyVideoTransition", json!({"effect": name})).unwrap_or_else(|e| panic!("{}: {e}", d.id)); + let q = s.active_sequence().unwrap(); + let tr = q.video_tracks[0].transitions.iter().find(|t| t.id.0 == r["transition"].as_u64().unwrap()).unwrap(); + assert_eq!(tr.effect.effect, d.id); + // rendering the middle of the transition works through the engine + let mid = tr.start + tr.duration.mul_ratio(1, 2); + s.execute("playhead.set", json!({"time": mid.0})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().video_tracks[0].transitions.iter().all(|t| t.id.0 != r["transition"].as_u64().unwrap())); + s.execute("playhead.set", json!({"time": cut.0})).unwrap(); + } + // names shared with video effects resolve to the transition here + let r = s.execute("sequence.applyVideoTransition", json!({"effect": "Mosaic"})).unwrap(); + let q = s.active_sequence().unwrap(); + assert!(q.video_tracks[0].transitions.iter().any(|t| t.id.0 == r["transition"].as_u64().unwrap() && t.effect.effect == "mosaic_transition")); + assert!(s.execute("sequence.applyVideoTransition", json!({"effect": "Constant Power"})).is_err()); +} + +#[test] +fn transition_params_and_reverse_via_commands() { + let mut s = demo(); + let cut = s.active_sequence().unwrap().video_tracks[0].items[2].start; + s.execute("playhead.set", json!({"time": cut.0})).unwrap(); + let r = s + .execute( + "sequence.applyVideoTransition", + json!({"effect": "Iris Round", "params": {"border_width": 12.0, "border_color": "#ff0000", "antialias": "High"}, "reverse": true}), + ) + .unwrap(); + let id = r["transition"].as_u64().unwrap(); + let find = |s: &Session| s.active_sequence().unwrap().video_tracks[0].transitions.iter().find(|t| t.id.0 == id).cloned().unwrap(); + let tr = find(&s); + assert!(tr.reverse); + assert_eq!(tr.effect.params["border_width"].value, filmcraft_project::ParamValue::Float(12.0)); + assert_eq!(tr.effect.params["antialias"].value, filmcraft_project::ParamValue::Choice(3)); + s.execute("sequence.setTransition", json!({"transition": id, "params": {"center": [100.0, 50.0], "border_width": 9999.0}, "reverse": false})).unwrap(); + let tr = find(&s); + assert!(!tr.reverse); + assert_eq!(tr.effect.params["border_width"].value, filmcraft_project::ParamValue::Float(200.0), "clamped to the range"); + assert!(s.execute("sequence.setTransition", json!({"transition": id, "params": {"nope": 1}})).is_err()); + assert!(s.execute("sequence.setTransition", json!({"transition": id, "params": {"antialias": "Ultra"}})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(find(&s).reverse, "setTransition is one undo step"); + s.execute("sequence.setTransition", json!({"transition": id, "reset": true})).unwrap(); + assert_eq!(find(&s).effect.params["border_width"].value, filmcraft_project::ParamValue::Float(0.0)); + let seq = s.execute("sequence.inspect", json!({})).unwrap(); + let js = seq["video"][0]["transitions"].as_array().unwrap().iter().find(|t| t["id"] == id).unwrap().clone(); + assert_eq!(js["effect"], "iris_round"); + assert!(js["params"]["border_width"].is_object() || js["params"]["border_width"].is_number(), "{js}"); +} + +#[test] +fn effects_list_reports_transition_folders() { + let s = demo(); + let mut s = s; + let all = s.execute("effects.list", json!({"kind": "VideoTransition"})).unwrap(); + let all = all.as_array().unwrap(); + assert!(all.iter().all(|e| e["kind"] == "VideoTransition" && e["folder"].is_string())); + let wipes = s.execute("effects.list", json!({"folder": "Video Transitions/Wipe", "detail": true})).unwrap(); + let wipes = wipes.as_array().unwrap(); + assert_eq!(wipes.len(), 9); + let lw = wipes.iter().find(|e| e["id"] == "linear_wipe").unwrap(); + assert_eq!(lw["folder"], "Video Transitions/Wipe"); + let aa = lw["paramInfo"].as_array().unwrap().iter().find(|p| p["id"] == "antialias").unwrap(); + assert_eq!(aa["type"], "choice"); + assert_eq!(aa["options"].as_array().unwrap().len(), 4); + let legacy = s.execute("effects.list", json!({"folder": "Legacy/Video Transitions"})).unwrap(); + assert_eq!(legacy.as_array().unwrap().len(), 21); + // the default transition accepts names shared with video effects + s.execute("effects.setDefaultTransition", json!({"effect": "Mosaic"})).unwrap(); + assert_eq!(s.state.default_video_transition, "mosaic_transition"); +} + #[test] fn commands_are_unique_and_described() { let mut ids = std::collections::HashSet::new(); diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index ff38f205..be82ece9 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -160,7 +160,7 @@ pub const BLEND_MODES: &[&str] = &[ "Luminosity", ]; -fn f(id: &'static str, label: &'static str, def: f64, min: f64, max: f64, unit: &'static str) -> ParamDef { +pub(crate) fn f(id: &'static str, label: &'static str, def: f64, min: f64, max: f64, unit: &'static str) -> ParamDef { ParamDef { id, label, @@ -170,7 +170,15 @@ fn f(id: &'static str, label: &'static str, def: f64, min: f64, max: f64, unit: group: None, } } -fn fs(id: &'static str, label: &'static str, def: f64, (min, max): (f64, f64), (smin, smax): (f64, f64), unit: &'static str, decimals: u8) -> ParamDef { +pub(crate) fn fs( + id: &'static str, + label: &'static str, + def: f64, + (min, max): (f64, f64), + (smin, smax): (f64, f64), + unit: &'static str, + decimals: u8, +) -> ParamDef { ParamDef { id, label, @@ -180,19 +188,19 @@ fn fs(id: &'static str, label: &'static str, def: f64, (min, max): (f64, f64), ( group: None, } } -fn pt(id: &'static str, label: &'static str, x: f64, y: f64) -> ParamDef { +pub(crate) fn pt(id: &'static str, label: &'static str, x: f64, y: f64) -> ParamDef { ParamDef { id, label, kind: ParamKind::Point, default: ParamValue::Vec2(Vec2::new(x, y)), animatable: true, group: None } } -fn col(id: &'static str, label: &'static str, c: [f32; 4]) -> ParamDef { +pub(crate) fn col(id: &'static str, label: &'static str, c: [f32; 4]) -> ParamDef { ParamDef { id, label, kind: ParamKind::Color, default: ParamValue::Color(c), animatable: true, group: None } } -fn b(id: &'static str, label: &'static str, v: bool) -> ParamDef { +pub(crate) fn b(id: &'static str, label: &'static str, v: bool) -> ParamDef { ParamDef { id, label, kind: ParamKind::Bool, default: ParamValue::Bool(v), animatable: false, group: None } } -fn ch(id: &'static str, label: &'static str, opts: &'static [&'static str], def: u32) -> ParamDef { +pub(crate) fn ch(id: &'static str, label: &'static str, opts: &'static [&'static str], def: u32) -> ParamDef { ParamDef { id, label, kind: ParamKind::Choice(opts), default: ParamValue::Choice(def), animatable: false, group: None } } -fn ang(id: &'static str, label: &'static str, def: f64) -> ParamDef { +pub(crate) fn ang(id: &'static str, label: &'static str, def: f64) -> ParamDef { ParamDef { id, label, kind: ParamKind::Angle, default: ParamValue::Float(def), animatable: true, group: None } } fn curve(id: &'static str, label: &'static str, hue: bool) -> ParamDef { @@ -202,7 +210,7 @@ fn curve(id: &'static str, label: &'static str, hue: bool) -> ParamDef { fn wheel(id: &'static str, label: &'static str) -> ParamDef { ParamDef { id, label, kind: ParamKind::Wheel, default: ParamValue::Vec2(Vec2::new(0.0, 0.0)), animatable: true, group: None } } -fn txt(id: &'static str, label: &'static str) -> ParamDef { +pub(crate) fn txt(id: &'static str, label: &'static str) -> ParamDef { ParamDef { id, label, kind: ParamKind::Text, default: ParamValue::Text(String::new()), animatable: false, group: None } } fn grp(mut p: ParamDef, g: &'static str) -> ParamDef { @@ -213,9 +221,6 @@ fn grp(mut p: ParamDef, g: &'static str) -> ParamDef { fn video(id: &'static str, name: &'static str, cat: &'static [&'static str], params: Vec) -> EffectDef { EffectDef { id, name, kind: EffectKind::Video, category: cat, params, intrinsic: false, accelerated: true, float32: true, yuv: false } } -fn vtrans(id: &'static str, name: &'static str, cat: &'static [&'static str], params: Vec) -> EffectDef { - EffectDef { id, name, kind: EffectKind::VideoTransition, category: cat, params, intrinsic: false, accelerated: true, float32: true, yuv: false } -} fn audio(id: &'static str, name: &'static str, cat: &'static [&'static str], params: Vec) -> EffectDef { EffectDef { id, name, kind: EffectKind::Audio, category: cat, params, intrinsic: false, accelerated: false, float32: true, yuv: false } } @@ -232,13 +237,6 @@ const PERSPECTIVE: &[&str] = &["Video Effects", "Perspective"]; const STYLIZE: &[&str] = &["Video Effects", "Stylize"]; const TRANSFORM: &[&str] = &["Video Effects", "Transform"]; const VIDEO: &[&str] = &["Video Effects", "Video"]; -const DISSOLVE: &[&str] = &["Video Transitions", "Dissolve"]; -const IRIS: &[&str] = &["Video Transitions", "Iris"]; -const SLIDE: &[&str] = &["Video Transitions", "Slide"]; -const WIPE: &[&str] = &["Video Transitions", "Wipe"]; -const ZOOM: &[&str] = &["Video Transitions", "Zoom"]; -const PAGE_PEEL: &[&str] = &["Video Transitions", "Page Peel"]; -const MOTION3D: &[&str] = &["Video Transitions", "3D Motion"]; const A_AMP: &[&str] = &["Audio Effects", "Amplitude and Compression"]; const A_DELAY: &[&str] = &["Audio Effects", "Delay and Echo"]; const A_FILTER: &[&str] = &["Audio Effects", "Filter and EQ"]; @@ -249,8 +247,6 @@ const A_STEREO: &[&str] = &["Audio Effects", "Stereo Imagery"]; const A_TIME: &[&str] = &["Audio Effects", "Time and Pitch"]; const A_TRANS: &[&str] = &["Audio Transitions", "Crossfade"]; -const DIR_OPTS: &[&str] = &["From North", "From East", "From South", "From West"]; - fn build_effects() -> Vec { let mut v = vec![ // ---- intrinsic ---- @@ -787,37 +783,6 @@ fn build_effects() -> Vec { vec![pt("position", "Position", f64::NAN, f64::NAN), fs("size", "Size", 15.0, (1.0, 100.0), (1.0, 50.0), "%", 1)], ), video("simple_text", "Simple Text", VIDEO, vec![]), - // ---- video transitions ---- - vtrans("cross_dissolve", "Cross Dissolve", DISSOLVE, vec![]), - vtrans("additive_dissolve", "Additive Dissolve", DISSOLVE, vec![]), - vtrans("dip_to_black", "Dip to Black", DISSOLVE, vec![]), - vtrans("dip_to_white", "Dip to White", DISSOLVE, vec![]), - vtrans("film_dissolve", "Film Dissolve", DISSOLVE, vec![]), - vtrans("morph_cut", "Morph Cut", DISSOLVE, vec![]), - vtrans("non_additive_dissolve", "Non-Additive Dissolve", DISSOLVE, vec![]), - vtrans("iris_box", "Iris Box", IRIS, vec![]), - vtrans("iris_cross", "Iris Cross", IRIS, vec![]), - vtrans("iris_diamond", "Iris Diamond", IRIS, vec![]), - vtrans("iris_round", "Iris Round", IRIS, vec![]), - vtrans("push", "Push", SLIDE, vec![ch("direction", "Direction", DIR_OPTS, 3)]), - vtrans("slide", "Slide", SLIDE, vec![ch("direction", "Direction", DIR_OPTS, 3)]), - vtrans("split", "Split", SLIDE, vec![]), - vtrans("center_split", "Center Split", SLIDE, vec![]), - vtrans("band_slide", "Band Slide", SLIDE, vec![]), - vtrans("whip", "Whip", SLIDE, vec![ch("direction", "Direction", DIR_OPTS, 3)]), - vtrans("wipe", "Wipe", WIPE, vec![ch("direction", "Direction", DIR_OPTS, 3)]), - vtrans("barn_doors", "Barn Doors", WIPE, vec![]), - vtrans("clock_wipe", "Clock Wipe", WIPE, vec![]), - vtrans("gradient_wipe", "Gradient Wipe", WIPE, vec![f("softness", "Softness", 10.0, 0.0, 100.0, "")]), - vtrans("inset", "Inset", WIPE, vec![]), - vtrans("radial_wipe", "Radial Wipe", WIPE, vec![]), - vtrans("venetian_blinds", "Venetian Blinds", WIPE, vec![]), - vtrans("checker_wipe", "Checker Wipe", WIPE, vec![]), - vtrans("cross_zoom", "Cross Zoom", ZOOM, vec![]), - vtrans("page_peel", "Page Peel", PAGE_PEEL, vec![]), - vtrans("page_turn", "Page Turn", PAGE_PEEL, vec![]), - vtrans("cube_spin", "Cube Spin", MOTION3D, vec![]), - vtrans("flip_over", "Flip Over", MOTION3D, vec![]), // ---- audio effects ---- audio("amplify", "Amplify", A_AMP, vec![fs("gain", "Gain", 0.0, (-96.0, 48.0), (-24.0, 24.0), "dB", 1)]), audio( @@ -957,6 +922,7 @@ fn build_effects() -> Vec { yuv: false, }, ]; + v.extend(crate::vtransition::video_transition_defs()); v.extend(crate::graphic::layer_defs()); // YUV badge for the colour/intrinsic set for e in &mut v { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 93f4b807..bb5d9f7a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -16,6 +16,7 @@ pub mod mask; pub mod mixer; pub mod multicam; pub mod transcript; +pub mod vtransition; use std::collections::BTreeMap; diff --git a/crates/project/src/vtransition.rs b/crates/project/src/vtransition.rs new file mode 100644 index 00000000..ddcc4c41 --- /dev/null +++ b/crates/project/src/vtransition.rs @@ -0,0 +1,446 @@ +//! Video transition definitions: the Effects-panel folders (Premiere 26 layout: 84 transitions in +//! 10 folders, plus the Legacy folder) and each transition's Effect Controls parameters. +//! +//! Transition parameters are not animatable (as in Premiere). Pixel implementations live in +//! `filmcraft-render` (`transitions`), looked up by id. Ids are stable: projects reference +//! transitions by id, so an id is never renamed (a few modern transitions reuse the id of the +//! transition they replaced, e.g. `push`, `clock_wipe`, `page_peel`; the old behaviour lives on +//! under a `*_legacy` id). + +use crate::effect::{EffectDef, EffectKind, ParamDef, ang, b, ch, col, fs, pt}; + +/// Top-level Effects panel folders, in Premiere's order. +pub const EFFECT_TOP_FOLDERS: &[&str] = &["Presets", "Lumetri Presets", "Audio Effects", "Audio Transitions", "Video Effects", "Video Transitions", "Legacy"]; + +/// The ten `Video Transitions` sub-folders, in panel order. +pub const VIDEO_TRANSITION_FOLDERS: &[&str] = + &["Animation", "Dissolve", "Grunge & Distort", "Immersive Video", "Lights & Blurs", "Slide", "Smart Tools", "Text", "Transformers", "Wipe"]; + +/// Category of the legacy transitions (`Legacy > Video Transitions`). +pub const LEGACY_VIDEO_TRANSITIONS: &[&str] = &["Legacy", "Video Transitions"]; +/// Category of transitions Premiere no longer lists (kept so old projects still render; not shown +/// in the Effects panel). +pub const OBSOLETE_VIDEO_TRANSITIONS: &[&str] = &["Obsolete", "Video Transitions"]; + +const ANIMATION: &[&str] = &["Video Transitions", "Animation"]; +const DISSOLVE: &[&str] = &["Video Transitions", "Dissolve"]; +const GRUNGE: &[&str] = &["Video Transitions", "Grunge & Distort"]; +const IMMERSIVE: &[&str] = &["Video Transitions", "Immersive Video"]; +const LIGHTS: &[&str] = &["Video Transitions", "Lights & Blurs"]; +const SLIDE: &[&str] = &["Video Transitions", "Slide"]; +const SMART: &[&str] = &["Video Transitions", "Smart Tools"]; +const TEXT: &[&str] = &["Video Transitions", "Text"]; +const TRANSFORMERS: &[&str] = &["Video Transitions", "Transformers"]; +const WIPE: &[&str] = &["Video Transitions", "Wipe"]; + +/// Direction choices (index 3, "From West", moves left → right). +pub const DIR_OPTS: &[&str] = &["From North", "From East", "From South", "From West"]; +pub const AA_OPTS: &[&str] = &["Off", "Low", "Medium", "High"]; +pub const AXIS_OPTS: &[&str] = &["Horizontal", "Vertical"]; +pub const CORNER_OPTS: &[&str] = &["Top Left", "Top Right", "Bottom Right", "Bottom Left"]; +pub const SHAPE_OPTS: &[&str] = &["Circle", "Square", "Diamond", "Star", "Heart", "Triangle", "Hexagon"]; +pub const SPIN_OPTS: &[&str] = &["Clockwise", "Counter-clockwise"]; +pub const ORDER_OPTS: &[&str] = &["Random", "Left to Right", "Center Out"]; + +/// Not animatable (transition parameters have no stopwatch in Premiere). +fn fixed(mut p: ParamDef) -> ParamDef { + p.animatable = false; + p +} +fn num(id: &'static str, label: &'static str, def: f64, max: f64, unit: &'static str) -> ParamDef { + fixed(fs(id, label, def, (0.0, max), (0.0, max), unit, 1)) +} +fn range(id: &'static str, label: &'static str, def: f64, min: f64, max: f64, unit: &'static str) -> ParamDef { + fixed(fs(id, label, def, (min, max), (min, max), unit, 0)) +} +fn colour(id: &'static str, label: &'static str, c: [f32; 4]) -> ParamDef { + fixed(col(id, label, c)) +} +fn angle(id: &'static str, label: &'static str, def: f64) -> ParamDef { + fixed(ang(id, label, def)) +} +fn centre() -> ParamDef { + fixed(pt("center", "Center", f64::NAN, f64::NAN)) +} +fn direction() -> ParamDef { + ch("direction", "Direction", DIR_OPTS, 3) +} +fn motion_blur() -> ParamDef { + num("motion_blur", "Motion Blur", 50.0, 100.0, "%") +} +fn seed() -> ParamDef { + range("seed", "Random Seed", 1.0, 0.0, 9999.0, "") +} +fn amount(def: f64) -> ParamDef { + num("amount", "Amount", def, 100.0, "%") +} +fn feather(def: f64) -> ParamDef { + fixed(fs("feather", "Feather", def, (0.0, 2000.0), (0.0, 500.0), "px", 0)) +} +/// Border Width / Border Color / Anti-aliasing Quality (Premiere's wipe and iris controls). +fn border() -> [ParamDef; 3] { + [ + num("border_width", "Border Width", 0.0, 200.0, "px"), + colour("border_color", "Border Color", [0.0, 0.0, 0.0, 1.0]), + ch("antialias", "Anti-aliasing Quality", AA_OPTS, 1), + ] +} + +#[derive(Clone, Copy)] +enum Badges { + /// Accelerated + 32-bit (every modern transition). + A32, + /// Accelerated only. + A, + /// Accelerated + YUV. + AYuv, + /// Accelerated + 32-bit + YUV. + A32Yuv, +} + +fn tr(id: &'static str, name: &'static str, cat: &'static [&'static str], badges: Badges, params: Vec) -> EffectDef { + let (float32, yuv) = match badges { + Badges::A32 => (true, false), + Badges::A => (false, false), + Badges::AYuv => (false, true), + Badges::A32Yuv => (true, true), + }; + EffectDef { id, name, kind: EffectKind::VideoTransition, category: cat, params, intrinsic: false, accelerated: true, float32, yuv } +} + +fn with(mut v: Vec, extra: impl IntoIterator) -> Vec { + v.extend(extra); + v +} + +pub(crate) fn video_transition_defs() -> Vec { + use Badges::*; + let m = A32; + vec![ + // ---- Animation ---- + tr("block_motion", "Block Motion", ANIMATION, m, vec![direction(), range("blocks", "Blocks", 6.0, 2.0, 32.0, ""), motion_blur()]), + tr("flip_motion", "Flip Motion", ANIMATION, m, vec![direction(), num("perspective", "Perspective", 50.0, 100.0, "%"), motion_blur()]), + tr("fold_motion", "Fold Motion", ANIMATION, m, vec![direction(), num("perspective", "Perspective", 50.0, 100.0, "%")]), + tr("pop_motion", "Pop Motion", ANIMATION, m, vec![range("overshoot", "Overshoot", 20.0, 0.0, 100.0, "%"), motion_blur()]), + tr("pull_motion", "Pull Motion", ANIMATION, m, vec![direction(), motion_blur()]), + tr( + "spin_motion", + "Spin Motion", + ANIMATION, + m, + vec![range("rotation", "Rotation", 360.0, 0.0, 1440.0, "°"), ch("spin", "Spin Direction", SPIN_OPTS, 0), motion_blur()], + ), + tr("spring_motion", "Spring Motion", ANIMATION, m, vec![direction(), range("bounce", "Bounce", 40.0, 0.0, 100.0, "%"), motion_blur()]), + tr("travel_motion", "Travel Motion", ANIMATION, m, vec![direction(), range("zoom", "Zoom Out", 40.0, 0.0, 90.0, "%"), motion_blur()]), + // ---- Dissolve ---- + tr("additive_dissolve", "Additive Dissolve", DISSOLVE, m, vec![]), + tr("blur_dissolve", "Blur Dissolve", DISSOLVE, m, vec![num("blur", "Blur", 40.0, 400.0, "px")]), + tr( + "burn_alpha", + "Burn Alpha", + DISSOLVE, + m, + vec![colour("burn_color", "Burn Color", [1.0, 0.45, 0.1, 1.0]), num("softness", "Softness", 25.0, 100.0, "%"), seed()], + ), + tr("cross_dissolve", "Cross Dissolve", DISSOLVE, m, vec![]), + tr("dip_to_black", "Dip to Black", DISSOLVE, m, vec![]), + tr("dip_to_color", "Dip to Color", DISSOLVE, m, vec![colour("color", "Color", [0.85, 0.2, 0.2, 1.0]), num("hold", "Hold", 0.0, 90.0, "%")]), + tr("dip_to_white", "Dip to White", DISSOLVE, m, vec![]), + tr("film_dissolve", "Film Dissolve", DISSOLVE, m, vec![]), + tr( + "luma_fade", + "Luma Fade", + DISSOLVE, + m, + vec![ch("source", "Luma Source", &["Outgoing", "Incoming"], 0), num("softness", "Softness", 20.0, 100.0, "%"), b("invert", "Invert", false)], + ), + tr("morph_cut", "Morph Cut", DISSOLVE, m, vec![]), + tr( + "mosaic_transition", + "Mosaic", + DISSOLVE, + m, + vec![num("block_size", "Maximum Block Size", 64.0, 400.0, "px"), b("dissolve", "Dissolve Blocks", true)], + ), + // ---- Grunge & Distort ---- + tr("chaos", "Chaos", GRUNGE, m, vec![amount(60.0), seed()]), + tr("earthquake", "Earthquake", GRUNGE, m, vec![num("shake", "Shake", 40.0, 400.0, "px"), seed(), motion_blur()]), + tr("flicker", "Flicker", GRUNGE, m, vec![range("flickers", "Flickers", 8.0, 1.0, 40.0, ""), seed()]), + tr("glass", "Glass", GRUNGE, m, vec![num("cell_size", "Shard Size", 90.0, 1000.0, "px"), num("refraction", "Refraction", 40.0, 400.0, "px"), seed()]), + tr("glitch", "Glitch", GRUNGE, m, vec![amount(60.0), seed()]), + tr( + "grunge", + "Grunge", + GRUNGE, + m, + vec![ + num("scale", "Scale", 120.0, 1000.0, "px"), + colour("edge_color", "Edge Color", [0.22, 0.14, 0.08, 1.0]), + num("edge_width", "Edge Width", 12.0, 200.0, "px"), + seed(), + ], + ), + tr("kaleidoscope", "Kaleidoscope", GRUNGE, m, vec![range("segments", "Segments", 6.0, 2.0, 32.0, ""), num("zoom", "Zoom", 30.0, 100.0, "%")]), + tr( + "liquid_distortion", + "Liquid Distortion", + GRUNGE, + m, + vec![num("amount", "Amount", 60.0, 500.0, "px"), num("scale", "Scale", 200.0, 2000.0, "px"), seed()], + ), + tr("tv_power", "TV Power", GRUNGE, m, vec![colour("glow_color", "Glow Color", [0.85, 0.95, 1.0, 1.0]), num("line", "Line Thickness", 4.0, 50.0, "px")]), + tr("vhs_damage", "VHS Damage", GRUNGE, m, vec![amount(60.0), seed()]), + // ---- Immersive Video (flat equirectangular approximations; see the render crate) ---- + tr("vr_chroma_leaks", "VR Chroma Leaks", IMMERSIVE, m, vec![amount(70.0), seed()]), + tr("vr_gradient_wipe", "VR Gradient Wipe", IMMERSIVE, m, vec![num("softness", "Softness", 20.0, 100.0, "%"), b("invert", "Invert Gradient", false)]), + tr("vr_iris_wipe", "VR Iris Wipe", IMMERSIVE, m, with(vec![centre(), feather(8.0)], border())), + tr("vr_light_leaks", "VR Light Leaks", IMMERSIVE, m, vec![amount(70.0), seed()]), + tr("vr_light_rays", "VR Light Rays", IMMERSIVE, m, vec![centre(), amount(70.0)]), + tr("vr_mobius_zoom", "VR Mobius Zoom", IMMERSIVE, m, vec![num("zoom", "Zoom", 60.0, 100.0, "%"), angle("twist", "Twist", 180.0)]), + tr( + "vr_random_blocks", + "VR Random Blocks", + IMMERSIVE, + m, + vec![num("block_size", "Block Size", 80.0, 1000.0, "px"), num("softness", "Softness", 10.0, 100.0, "%"), seed()], + ), + tr("vr_spherical_blur", "VR Spherical Blur", IMMERSIVE, m, vec![num("blur", "Blur", 60.0, 400.0, "px")]), + // ---- Lights & Blurs ---- + tr("burn_chroma", "Burn Chroma", LIGHTS, m, vec![amount(80.0)]), + tr("chroma_leak", "Chroma Leak", LIGHTS, m, vec![amount(70.0), seed()]), + tr("cross_zoom", "Cross Zoom", LIGHTS, m, vec![centre(), num("strength", "Zoom Strength", 60.0, 100.0, "%")]), + tr("directional_blur_transition", "Directional Blur", LIGHTS, m, vec![angle("angle", "Angle", 90.0), num("blur", "Blur Length", 160.0, 1000.0, "px")]), + tr("flare", "Flare", LIGHTS, m, vec![colour("color", "Color", [1.0, 0.8, 0.55, 1.0]), amount(80.0), angle("angle", "Angle", 20.0)]), + tr("flash", "Flash", LIGHTS, m, vec![colour("color", "Color", [1.0, 1.0, 1.0, 1.0]), num("intensity", "Intensity", 100.0, 100.0, "%")]), + tr("glow", "Glow", LIGHTS, m, vec![num("radius", "Radius", 40.0, 400.0, "px"), num("intensity", "Intensity", 80.0, 100.0, "%")]), + tr("lens_blur", "Lens Blur", LIGHTS, m, vec![num("radius", "Iris Radius", 30.0, 200.0, "px")]), + tr("light_leak", "Light Leak", LIGHTS, m, vec![colour("color", "Color", [1.0, 0.55, 0.2, 1.0]), amount(80.0), seed()]), + tr( + "light_sweep", + "Light Sweep", + LIGHTS, + m, + vec![angle("angle", "Angle", 70.0), num("width", "Width", 160.0, 1000.0, "px"), colour("color", "Color", [1.0, 0.97, 0.9, 1.0]), amount(80.0)], + ), + tr("phosphor", "Phosphor", LIGHTS, m, vec![colour("color", "Phosphor Color", [0.55, 1.0, 0.6, 1.0]), num("decay", "Decay", 50.0, 100.0, "%")]), + tr("radial_blur", "Radial Blur", LIGHTS, m, vec![centre(), range("angle", "Blur Angle", 40.0, 0.0, 180.0, "°")]), + tr("ray", "Ray", LIGHTS, m, vec![centre(), amount(80.0), num("length", "Ray Length", 50.0, 100.0, "%")]), + tr("solarize", "Solarize", LIGHTS, m, vec![amount(100.0)]), + tr( + "stripe", + "Stripe", + LIGHTS, + m, + vec![range("stripes", "Stripes", 8.0, 1.0, 64.0, ""), angle("angle", "Angle", 30.0), colour("color", "Color", [1.0, 1.0, 1.0, 1.0])], + ), + tr("zoom_blur", "Zoom Blur", LIGHTS, m, vec![centre(), num("strength", "Strength", 50.0, 100.0, "%")]), + // ---- Slide ---- + tr("roll_3d", "3D Roll", SLIDE, m, vec![direction(), num("perspective", "Perspective", 50.0, 100.0, "%")]), + tr( + "film_roll", + "Film Roll", + SLIDE, + m, + vec![direction(), num("gap", "Gap", 40.0, 400.0, "px"), colour("gap_color", "Gap Color", [0.03, 0.03, 0.03, 1.0]), motion_blur()], + ), + tr("push", "Push", SLIDE, m, vec![direction(), motion_blur()]), + tr("roll", "Roll", SLIDE, m, vec![direction(), motion_blur()]), + tr("slide", "Slide", SLIDE, m, vec![direction(), motion_blur()]), + tr("split", "Split", SLIDE, m, vec![ch("orientation", "Orientation", AXIS_OPTS, 0), motion_blur()]), + tr("stretch", "Stretch", SLIDE, m, vec![direction()]), + tr("whip", "Whip", SLIDE, m, vec![direction(), num("blur", "Blur", 80.0, 100.0, "%")]), + // ---- Smart Tools ---- + tr("motion_camera", "Motion Camera", SMART, m, vec![range("zoom", "Zoom", 40.0, 0.0, 200.0, "%"), angle("rotation", "Rotation", 8.0), motion_blur()]), + tr("motion_tween", "Motion Tween", SMART, m, vec![range("scale", "Scale Match", 15.0, 0.0, 100.0, "%")]), + tr( + "shape_dissolve", + "Shape Dissolve", + SMART, + m, + vec![ + ch("shape", "Shape", SHAPE_OPTS, 0), + num("size", "Shape Size", 120.0, 1000.0, "px"), + num("softness", "Softness", 2.0, 100.0, "px"), + angle("rotation", "Rotation", 0.0), + ch("order", "Order", ORDER_OPTS, 0), + seed(), + ], + ), + tr("shape_flow", "Shape Flow", SMART, m, vec![ch("shape", "Shape", SHAPE_OPTS, 0), direction(), num("softness", "Softness", 4.0, 200.0, "px")]), + // ---- Text ---- + tr("text_animator", "Text Animator", TEXT, m, vec![range("columns", "Columns", 24.0, 2.0, 128.0, ""), range("rows", "Rows", 10.0, 1.0, 64.0, "")]), + tr( + "typewriter", + "Typewriter", + TEXT, + m, + vec![range("columns", "Columns", 32.0, 2.0, 128.0, ""), range("rows", "Rows", 12.0, 1.0, 64.0, ""), b("cursor", "Cursor", true)], + ), + // ---- Transformers ---- + tr("spin_3d", "3D Spin", TRANSFORMERS, m, vec![direction(), num("perspective", "Perspective", 50.0, 100.0, "%")]), + tr("spinback_3d", "3D Spinback", TRANSFORMERS, m, vec![direction(), num("depth", "Depth", 60.0, 100.0, "%")]), + tr( + "frame", + "Frame", + TRANSFORMERS, + m, + vec![ + num("frame_width", "Frame Width", 24.0, 200.0, "px"), + colour("frame_color", "Frame Color", [1.0, 1.0, 1.0, 1.0]), + range("scale", "Scale", 60.0, 10.0, 95.0, "%"), + ], + ), + tr("louver", "Louver", TRANSFORMERS, m, vec![range("slats", "Slats", 8.0, 1.0, 64.0, ""), ch("orientation", "Orientation", AXIS_OPTS, 0)]), + tr("mirror_transition", "Mirror", TRANSFORMERS, m, vec![direction()]), + tr( + "page_peel", + "Page Peel", + TRANSFORMERS, + m, + vec![ch("corner", "Corner", CORNER_OPTS, 2), num("radius", "Curl Radius", 60.0, 500.0, "px"), b("shadow", "Shadow", true)], + ), + tr("slice", "Slice", TRANSFORMERS, m, vec![range("slices", "Slices", 8.0, 1.0, 64.0, ""), angle("angle", "Angle", 20.0), motion_blur()]), + tr( + "wave", + "Wave", + TRANSFORMERS, + m, + vec![direction(), num("amplitude", "Amplitude", 60.0, 500.0, "px"), num("wavelength", "Wavelength", 300.0, 3000.0, "px")], + ), + // ---- Wipe ---- + tr("clock_wipe", "Clock Wipe", WIPE, m, with(vec![centre(), angle("start", "Start Angle", 0.0), feather(0.0)], border())), + tr("linear_wipe", "Linear Wipe", WIPE, m, with(vec![angle("angle", "Wipe Angle", 90.0), feather(0.0)], border())), + tr( + "neon_wipe", + "Neon Wipe", + WIPE, + m, + vec![ + angle("angle", "Wipe Angle", 90.0), + colour("color", "Neon Color", [0.2, 0.9, 1.0, 1.0]), + num("glow", "Glow Width", 40.0, 400.0, "px"), + amount(100.0), + ], + ), + tr("panel_wipe", "Panel Wipe", WIPE, m, with(vec![direction(), range("panels", "Panels", 6.0, 1.0, 64.0, ""), feather(0.0)], border())), + tr( + "plateau_wipe", + "Plateau Wipe", + WIPE, + m, + with(vec![angle("angle", "Wipe Angle", 90.0), range("plateaus", "Plateaus", 5.0, 1.0, 64.0, ""), feather(4.0)], border()), + ), + tr("radial_wipe", "Radial Wipe", WIPE, m, with(vec![ch("corner", "Corner", CORNER_OPTS, 0), feather(0.0)], border())), + tr("soft_wipe", "Soft Wipe", WIPE, m, vec![angle("angle", "Wipe Angle", 90.0), feather(300.0)]), + tr( + "star_wipe", + "Star Wipe", + WIPE, + m, + with( + vec![ + centre(), + range("points", "Points", 5.0, 3.0, 24.0, ""), + range("inner", "Inner Radius", 45.0, 5.0, 100.0, "%"), + angle("rotation", "Rotation", 0.0), + feather(0.0), + ], + border(), + ), + ), + tr("stretch_wipe", "Stretch Wipe", WIPE, m, vec![direction(), num("stretch", "Stretch", 200.0, 2000.0, "px")]), + // ---- Legacy ---- + tr("additive_dissolve_legacy", "Additive Dissolve (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("barn_doors", "Barn Doors", LEGACY_VIDEO_TRANSITIONS, A, with(vec![ch("orientation", "Orientation", AXIS_OPTS, 1)], border())), + tr("center_split", "Center Split", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("clock_wipe_legacy", "Clock Wipe (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("cross_dissolve_legacy", "Cross Dissolve (Legacy)", LEGACY_VIDEO_TRANSITIONS, A32Yuv, vec![]), + tr("cross_zoom_legacy", "Cross Zoom (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("dip_to_black_legacy", "Dip to Black (Legacy)", LEGACY_VIDEO_TRANSITIONS, A32Yuv, vec![]), + tr("dip_to_white_legacy", "Dip to White (Legacy)", LEGACY_VIDEO_TRANSITIONS, A32Yuv, vec![]), + tr("film_dissolve_legacy", "Film Dissolve (Legacy)", LEGACY_VIDEO_TRANSITIONS, A32Yuv, vec![]), + tr("inset", "Inset", LEGACY_VIDEO_TRANSITIONS, A, with(vec![ch("corner", "Corner", CORNER_OPTS, 0)], border())), + tr("iris_box", "Iris Box", LEGACY_VIDEO_TRANSITIONS, AYuv, with(vec![centre()], border())), + tr("iris_cross", "Iris Cross", LEGACY_VIDEO_TRANSITIONS, AYuv, with(vec![centre()], border())), + tr("iris_diamond", "Iris Diamond", LEGACY_VIDEO_TRANSITIONS, AYuv, with(vec![centre()], border())), + tr("iris_round", "Iris Round", LEGACY_VIDEO_TRANSITIONS, AYuv, with(vec![centre()], border())), + tr("non_additive_dissolve", "Non-Additive Dissolve", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("push_legacy", "Push (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![direction()]), + tr("radial_wipe_legacy", "Radial Wipe (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("slide_legacy", "Slide (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![direction()]), + tr("split_legacy", "Split (Legacy)", LEGACY_VIDEO_TRANSITIONS, A, vec![]), + tr("whip_legacy", "Whip (Legacy)", LEGACY_VIDEO_TRANSITIONS, A32Yuv, vec![direction()]), + tr("wipe", "Wipe", LEGACY_VIDEO_TRANSITIONS, AYuv, vec![direction()]), + // ---- Obsolete (hidden; still render in old projects) ---- + tr("band_slide", "Band Slide", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + tr("checker_wipe", "Checker Wipe", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + tr("cube_spin", "Cube Spin", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + tr("flip_over", "Flip Over", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + tr("gradient_wipe", "Gradient Wipe", OBSOLETE_VIDEO_TRANSITIONS, A, vec![fixed(crate::effect::f("softness", "Softness", 10.0, 0.0, 100.0, ""))]), + tr("page_turn", "Page Turn", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + tr("venetian_blinds", "Venetian Blinds", OBSOLETE_VIDEO_TRANSITIONS, A, vec![]), + ] +} + +/// Case-insensitive lookup of a transition of `kind` by id or display name (names such as +/// "Mosaic" or "Mirror" also name video effects, so plain name lookup is ambiguous). +pub fn find_transition(name: &str, kind: EffectKind) -> Option<&'static EffectDef> { + let n = name.trim().to_ascii_lowercase(); + crate::effect::effect_defs().iter().filter(|d| d.kind == kind).find(|d| d.id == n || d.name.to_ascii_lowercase() == n) +} + +/// The Effects panel folder tree for video transitions: `(folder path, transition ids)` in panel +/// order (sub-folders as [`VIDEO_TRANSITION_FOLDERS`], then `Legacy/Video Transitions`); items are +/// sorted by name as in the panel. +pub fn video_transition_tree() -> Vec<(String, Vec<&'static str>)> { + let defs = crate::effect::effect_defs(); + let folder = |cat: &[&str]| -> Vec<&'static str> { + let mut v: Vec<&EffectDef> = defs.iter().filter(|d| d.kind == EffectKind::VideoTransition && d.category == cat).collect(); + v.sort_by_key(|d| d.name.to_ascii_lowercase()); + v.into_iter().map(|d| d.id).collect() + }; + let mut out: Vec<(String, Vec<&'static str>)> = + VIDEO_TRANSITION_FOLDERS.iter().map(|f| (format!("Video Transitions/{f}"), folder(&["Video Transitions", f]))).collect(); + out.push(("Legacy/Video Transitions".into(), folder(LEGACY_VIDEO_TRANSITIONS))); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premiere_26_transition_tree() { + let tree = video_transition_tree(); + let counts: Vec = tree.iter().map(|(_, v)| v.len()).collect(); + assert_eq!(counts, vec![8, 11, 10, 8, 16, 8, 4, 2, 8, 9, 21], "{tree:?}"); + assert_eq!(counts[..10].iter().sum::(), 84); + // every transition is in a known folder, ids and names are unique per kind + let mut names = std::collections::HashSet::new(); + for d in crate::effect::effect_defs().iter().filter(|d| d.kind == EffectKind::VideoTransition) { + assert!(names.insert(d.name), "duplicate transition name {}", d.name); + let ok = (d.category.len() == 2 && d.category[0] == "Video Transitions" && VIDEO_TRANSITION_FOLDERS.contains(&d.category[1])) + || d.category == LEGACY_VIDEO_TRANSITIONS + || d.category == OBSOLETE_VIDEO_TRANSITIONS; + assert!(ok, "{} in {:?}", d.id, d.category); + assert!(d.params.iter().all(|p| !p.animatable), "{}: transition params are not animatable", d.id); + } + // spot checks: names in the right folders + let at = |id: &str| crate::effect::find_effect(id).unwrap().category; + assert_eq!(at("cross_dissolve"), &["Video Transitions", "Dissolve"]); + assert_eq!(at("page_peel"), &["Video Transitions", "Transformers"]); + assert_eq!(at("cross_zoom"), &["Video Transitions", "Lights & Blurs"]); + assert_eq!(at("shape_dissolve"), &["Video Transitions", "Smart Tools"]); + assert_eq!(at("iris_round"), LEGACY_VIDEO_TRANSITIONS); + assert_eq!(at("venetian_blinds"), OBSOLETE_VIDEO_TRANSITIONS); + } + + #[test] + fn transition_lookup_by_kind() { + assert_eq!(find_transition("Mosaic", EffectKind::VideoTransition).unwrap().id, "mosaic_transition"); + assert_eq!(crate::effect::find_effect_by_name("Mosaic").unwrap().kind, EffectKind::Video); + assert_eq!(find_transition("push (legacy)", EffectKind::VideoTransition).unwrap().id, "push_legacy"); + assert_eq!(find_transition("3D Spinback", EffectKind::VideoTransition).unwrap().id, "spinback_3d"); + assert!(find_transition("Constant Power", EffectKind::VideoTransition).is_none()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f44e422e..671ebc55 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -113,11 +113,14 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts .and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc)) .map(|(img, op, _)| with_opacity(img, op)) .unwrap_or_else(|| Image::new(w, h)); - let mut p = tr.progress(t) as f32; - if tr.reverse { - p = 1.0 - p; - } - let mixed = transitions::apply(&tr.effect, &la, &lb, p); + let p = tr.progress(t) as f32; + // Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip) + // while still going from A to B. + let mixed = if tr.reverse { + transitions::apply_scaled(&tr.effect, &lb, &la, 1.0 - p, opts.scale) + } else { + transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale) + }; blend::composite(&mut canvas, &mixed, 1.0, Blend::Normal); continue; } diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 79c25f1a..76e74ab8 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -39,7 +39,7 @@ fn cpu_frame(img: crate::Image) -> Arc { } fn simple_transition(id: &str) -> bool { - matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve" | "morph_cut") + matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "morph_cut") } /// Whether a track item can be drawn by the GPU as-is (no standard effects, Normal blend). @@ -83,10 +83,8 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption continue; } if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) { - let mut p = trn.progress(t) as f32; - if trn.reverse { - p = 1.0 - p; - } + // These dissolves are symmetric: Reverse (play B→A backwards) renders the same frames. + let p = trn.progress(t) as f32; let a = trn.from.and_then(|id| tr.item(id)).filter(|i| i.enabled); let b = trn.to.and_then(|id| tr.item(id)).filter(|i| i.enabled); match trn.effect.effect.as_str() { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d22ca440..36ada7ea 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -134,6 +134,17 @@ fn cross_dissolve_midpoint() { let img = render_sequence(&p, seq, r.tick_of(24), RenderOptions::default(), &map); let c = img.get(10, 10); assert!((c[0] - 0.5).abs() < 0.05 && (c[2] - 0.5).abs() < 0.05, "{c:?}"); + // Iris Round at 50 %: B opens in the centre; reversed, A closes into the centre instead. + for reverse in [false, true] { + let trs = &mut p.sequence_mut(seq).unwrap().video_tracks[0].transitions; + trs[0].effect = filmcraft_project::find_effect("iris_round").unwrap().instance(); + trs[0].reverse = reverse; + let opts = RenderOptions { scale: 0.25, ..RenderOptions::default() }; + let img = render_sequence(&p, seq, r.tick_of(24), opts, &map); + let (mid, corner) = (img.get(img.w / 2, img.h / 2), img.get(2, 2)); + let (centre_is_b, corner_is_b) = (mid[2] > 0.9 && mid[0] < 0.1, corner[2] > 0.9 && corner[0] < 0.1); + assert_eq!((centre_is_b, corner_is_b), (!reverse, reverse), "reverse={reverse}: {mid:?} {corner:?}"); + } } #[test] diff --git a/crates/render/src/transitions/cards.rs b/crates/render/src/transitions/cards.rs new file mode 100644 index 00000000..27f4816b --- /dev/null +++ b/crates/render/src/transitions/cards.rs @@ -0,0 +1,103 @@ +//! Textured 3D cards under a pinhole camera, for flip / spin / cube / fold transitions. +//! +//! World space is pixel units: the screen is the plane z = 0 spanning (0..w, 0..h), +z points +//! away from the viewer and the eye sits at (w/2, h/2, −focal). A card at z = 0 with the identity +//! transform therefore covers the frame pixel-for-pixel. + +/// 3-vector. +pub type V3 = [f32; 3]; + +#[inline] +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +#[inline] +fn cross(a: V3, b: V3) -> V3 { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +#[inline] +fn dot(a: V3, b: V3) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// An affine placement of a card: world = origin + lx·ex + ly·ey for image pixel (lx, ly). +#[derive(Clone, Copy, Debug)] +pub struct Card { + pub origin: V3, + pub ex: V3, + pub ey: V3, +} + +impl Card { + /// The identity card (covers the frame at z = 0). + pub fn flat() -> Self { + Card { origin: [0.0; 3], ex: [1.0, 0.0, 0.0], ey: [0.0, 1.0, 0.0] } + } + fn map(&self, f: impl Fn(V3) -> V3, lin: impl Fn(V3) -> V3) -> Self { + Card { origin: f(self.origin), ex: lin(self.ex), ey: lin(self.ey) } + } + pub fn translate(self, d: V3) -> Self { + self.map(|p| [p[0] + d[0], p[1] + d[1], p[2] + d[2]], |v| v) + } + /// Uniform scale about `c`. + pub fn scale_about(self, c: V3, s: f32) -> Self { + self.map(|p| [c[0] + (p[0] - c[0]) * s, c[1] + (p[1] - c[1]) * s, c[2] + (p[2] - c[2]) * s], |v| [v[0] * s, v[1] * s, v[2] * s]) + } + /// Rotate about the vertical (y) axis through `c` by `a` radians (positive turns the right edge away). + pub fn rot_y(self, c: V3, a: f32) -> Self { + let (s, co) = a.sin_cos(); + let r = move |v: V3| [v[0] * co - v[2] * s, v[1], v[0] * s + v[2] * co]; + self.map(move |p| add(c, r(sub(p, c))), r) + } + /// Rotate about the horizontal (x) axis through `c` (positive turns the bottom edge away). + pub fn rot_x(self, c: V3, a: f32) -> Self { + let (s, co) = a.sin_cos(); + let r = move |v: V3| [v[0], v[1] * co - v[2] * s, v[1] * s + v[2] * co]; + self.map(move |p| add(c, r(sub(p, c))), r) + } + /// Rotate in the screen plane about `c` (positive = clockwise on screen). + pub fn rot_z(self, c: V3, a: f32) -> Self { + let (s, co) = a.sin_cos(); + let r = move |v: V3| [v[0] * co - v[1] * s, v[0] * s + v[1] * co, v[2]]; + self.map(move |p| add(c, r(sub(p, c))), r) + } + + /// Intersect the eye ray through screen point (x, y): local image coords, depth along the + /// ray, and whether the front face (the side with the normal towards the eye) is seen. + #[inline] + pub fn hit(&self, eye: V3, x: f32, y: f32) -> Option<(f32, f32, f32, bool)> { + let d = sub([x, y, 0.0], eye); + // eye + t·d = origin + lx·ex + ly·ey → lx·ex + ly·ey − t·d = eye − origin + let rhs = sub(eye, self.origin); + let nd = [-d[0], -d[1], -d[2]]; + let det = dot(self.ex, cross(self.ey, nd)); + if det.abs() < 1e-9 { + return None; + } + let lx = dot(rhs, cross(self.ey, nd)) / det; + let ly = dot(self.ex, cross(rhs, nd)) / det; + let t = dot(self.ex, cross(self.ey, rhs)) / det; + if t <= 1e-4 { + return None; + } + let n = cross(self.ex, self.ey); // +z for the flat card (faces away from the eye at −z) + let front = dot(n, d) > 0.0; + Some((lx, ly, t, front)) + } +} + +#[inline] +fn add(a: V3, b: V3) -> V3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +/// The camera eye for a frame of `w`×`h` with a focal distance of `focal` pixels. +pub fn eye(w: f32, h: f32, focal: f32) -> V3 { + [w / 2.0, h / 2.0, -focal.max(1.0)] +} + +/// Focal distance for a "Perspective" percentage (0 % = nearly orthographic, 100 % = wide angle). +pub fn focal_for(w: f32, h: f32, perspective: f32) -> f32 { + let d = w.max(h); + d * (6.0 - 5.0 * perspective.clamp(0.0, 1.0)).max(0.6) +} diff --git a/crates/render/src/transitions/dissolve.rs b/crates/render/src/transitions/dissolve.rs new file mode 100644 index 00000000..208a74ee --- /dev/null +++ b/crates/render/src/transitions/dissolve.rs @@ -0,0 +1,99 @@ +//! Dissolves (the classic Cross Dissolve / Dip / Film / Additive family stays in `legacy.rs`, +//! whose output the golden images pin). + +use super::{Tx, add_light, bell, blurred, fbm, hash, luma, mid_mix, mix, paint, scale4, smoothstep}; +use crate::image::Image; + +/// Perceptual (≈ gamma 2.2) luma of a premultiplied linear pixel, 0..1. +pub(crate) fn perceptual_luma(c: [f32; 4]) -> f32 { + luma(c).clamp(0.0, 1.0).powf(1.0 / 2.2) +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let p = t.p; + Some(match id { + "blur_dissolve" => { + let sigma = t.px("blur") * 0.5; + let k = smoothstep(0.25, 0.75, p); + let sa = sigma * smoothstep(0.0, 0.6, p); + let sb = sigma * (1.0 - smoothstep(0.4, 1.0, p)); + let ba = if k < 1.0 { blurred(t.a, sa) } else { t.a.clone() }; + let bb = if k > 0.0 { blurred(t.b, sb) } else { t.b.clone() }; + ba.lerp(&bb, k) + } + "burn_alpha" => { + let col = t.color("burn_color"); + let soft = t.frac("softness").clamp(0.01, 1.0) * 0.3; + let seed = t.seed(); + let scale = (220.0 * t.unit()).max(1.0); + let thr = -soft + p * (1.0 + 2.0 * soft); + let fade = (p * 10.0).min((1.0 - p) * 10.0).min(1.0); + paint(t.w, t.h, |x, y| { + let a = t.pa(x, y); + // burn order: dark areas of A and the noise go first + let g = 0.55 * fbm(x / scale, y / scale, seed) + 0.45 * (1.0 - perceptual_luma(a)); + let d = g - thr; // < 0 burnt through + let k = 1.0 - smoothstep(-soft * 0.3, soft * 0.3, d); + let base = mix(a, t.pb(x, y), k); + let edge = (-(d / (soft * 0.6)).powi(2)).exp() * fade; + let charred = if d > 0.0 { 1.0 - 0.6 * (-(d / soft).powi(2)).exp() * fade } else { 1.0 }; + add_light([base[0] * charred, base[1] * charred, base[2] * charred, base[3]], col, edge * 2.0) + }) + } + "dip_to_color" => { + let col = t.color("color"); + let hold = t.frac("hold").clamp(0.0, 0.9); + let half = (1.0 - hold) / 2.0; + paint(t.w, t.h, |x, y| { + if p < half { + mix(t.pa(x, y), col, p / half) + } else if p > 1.0 - half { + mix(col, t.pb(x, y), (p - (1.0 - half)) / half) + } else { + col + } + }) + } + "luma_fade" => { + let soft = t.frac("softness").clamp(0.005, 1.0) * 0.5; + let from_b = t.choice("source") == 1; + let inv = t.flag("invert"); + let thr = -soft + p * (1.0 + 2.0 * soft); + paint(t.w, t.h, |x, y| { + let (a, b) = (t.pa(x, y), t.pb(x, y)); + let l = perceptual_luma(if from_b { b } else { a }); + let g = if inv { l } else { 1.0 - l }; + mix(a, b, smoothstep(-soft, soft, thr - g)) + }) + } + "mosaic_transition" => { + let max = t.px("block_size").max(1.0); + let bs = 1.0 + (max - 1.0) * bell(p); + let k = mid_mix(p); + let per_block = t.flag("dissolve"); + paint(t.w, t.h, |x, y| { + if bs < 1.5 { + return mix(t.pa(x, y), t.pb(x, y), k); + } + let (bx, by) = ((x / bs).floor(), (y / bs).floor()); + let (sx, sy) = ((bx + 0.5) * bs, (by + 0.5) * bs); + let kk = if per_block { + let r = hash(bx as i32, by as i32, 7); + smoothstep(r - 0.15, r + 0.15, k * 1.3 - 0.15) + } else { + k + }; + mix(t.sac(sx, sy), t.sbc(sx, sy), kk) + }) + } + "non_additive_dissolve" => { + let (ka, kb) = ((2.0 * (1.0 - p)).min(1.0), (2.0 * p).min(1.0)); + paint(t.w, t.h, |x, y| { + let a = scale4(t.pa(x, y), ka); + let b = scale4(t.pb(x, y), kb); + if luma(a) >= luma(b) { a } else { b } + }) + } + _ => return None, + }) +} diff --git a/crates/render/src/transitions/goldens.txt b/crates/render/src/transitions/goldens.txt new file mode 100644 index 00000000..91f56bba --- /dev/null +++ b/crates/render/src/transitions/goldens.txt @@ -0,0 +1,336 @@ +block_motion 0.25 5c2320892320a01f1e6110125c3620893620982f1c56170b5c49208949208f3b1b7f2c165c5c20895c207e4216c7571e +block_motion 0.50 4124330c1a303f212d173060413630382c277394780e3b7b41434a4136283c718c124a9a28476a455240203b4d215fb8 +block_motion 0.75 0a2f610d3976224f761c356707254f0c438f8bb57d22416d07316b0b479b3e739c2150930c5ac80944980d60d60d60d6 +flip_motion 0.25 4c1d1b781f1ca01e1d98171450301c873520b5351fb62a1a51421d86491fb64920b53b194b4c1a78531c9f501b9a3f15 +flip_motion 0.50 0000000504060605080000000000000606090c0b0b00000000000006070b09090d00000000000005080906080e000000 +flip_motion 0.75 0a2e5e0b33681840670a2c5a0b3e850d46958fc8950b3d820b4aa30d53b7367cb70b49a00a4dab0b55bf0b55be0a4aa5 +fold_motion 0.25 0d39750d3975b4231fe223200d46950d4696b43620e236200d53b50d53b6b4491fe249200d5fd40d5fd5b45c20e25c20 +fold_motion 0.50 0d39750d3975b62320e223200d46950d4696b63620e236200d53b50d53b6b64920e249200d5fd40d5fd5b65c20e25c20 +fold_motion 0.75 0d39750d3975ae2423df231f0d46950d4696b03923df36200d53b50d53b6ae4925df481f0d5fd40d5fd5ad5b25df5b1f +pop_motion 0.25 54201d82211eaf211fcf201d58341e473a52794e54d9341f58461f4a5275685871d9461e54551d83591fae591ecf551d +pop_motion 0.50 09264e0a2f5f14385f09264e0a397a0d45948ac2940a397a0a44960d54b7337ab70a44960941910a4fb00a4fb0094191 +pop_motion 0.75 0d3a770d3a772653770d3a770d46960d46969dd7960d46960d53b50d53b53d83b50d53b50d5fd40d5fd40d5fd40d5fd4 +pull_motion 0.25 13060a4313126213106c100f171108753420af3422bc2b1b1a190b754c20ad4c20bb3f1b1012074437126437126c2e0f +pull_motion 0.50 061c38030e1c18080a4110105e95900622482a1a0f823320256dbb062b5e2c2610824c2006316d031837161809413010 +pull_motion 0.75 061b37072042082142020b160b39792c6492528a9204162f0b469b145cba226aba041c3d062f6907387e07387e021329 +spin_motion 0.25 2b1d0f79292254110f050101433a17843d21b02e1ea219164a441a8b511dbe4221b5291a030301523211c5561e7f2810 +spin_motion 0.50 000000000000000000000000000000020102010102000000000000020101010101000000000000000000000000000000 +spin_motion 0.75 000103071e3b2a5a7f072651092a56194d8878b6a10a41900a36730d4eaa1661c309479e072d640d5bca073373000306 +spring_motion 0.25 0d39761a47761441763c2c470d4696649d96437c96343c4d0d53b62a70b61e64b6394d5f0d60d60d60d60d60d6375d69 +spring_motion 0.50 092a560d3976204d760e3b7609336d0d469679b2962d6796093c840d53b6347ab6135ab609469b0d60d60d60d60d60d6 +spring_motion 0.75 0d39760d3976224f760e39740d46960f489697d1960d45930d53b60d53b63b81b60e52b20d60d60d60d60d60d60e5fd1 +travel_motion 0.25 070d19250a0b3b0a0a25050413335f713120b5312075191113427e714e20b54e2075271007193025200b3b200a251004 +travel_motion 0.50 030b16030a141405072306062b608b28587d5d2b1eaf30201560c01457ad5d481eaf502003142e031229141207231406 +travel_motion 0.75 02091204122404122414090c0622471f558e42788e3d2928062b5f105abe1d67be3d3e2f021024042048042048141911 +additive_dissolve 0.25 63405b8f405bc54a5be8405b63596b8f596bf3a06be6596b63737b8f737bd08a7be7737b638c8b8f8c8bbc8c8be68c8b +additive_dissolve 0.50 695c96965c96cb7196ee5c96697cb6967cb6fbf4b6e97cb6699cd6969cd6d5bdd6ed9cd669bcf096bcf0c2bcf0ebbcf0 +additive_dissolve 0.75 3b4b86514b867c60867e4b863b61a65161a6eeeda67e61a63b78c65178c695a2c67e78c63b8ee5518ee5688ee57e8ee5 +blur_dissolve 0.25 5f2422892420b62420df241e5d361f893620b63620e236215e4920894920b64920e1491f5d5b20895b20b65b20e15b20 +blur_dissolve 0.50 38335151395070425079394f383f5c53475c75525b7c465b374e6a51536a705c6a7a536a3859764d5b76675f76775b75 +blur_dissolve 0.75 0d3a780f3d782b59780f3c780d469617509670a996154e960d53b61157b63b81b61056b60d5fd40d5fd40e60d40d5fd4 +burn_alpha 0.25 5c2320892320b52320e223205c3620893620b43620e236205c4920894920b64920ea491f5c5c20875b1fb55c20e05b1f +burn_alpha 0.50 d83f34d0464cff4e439d5176ff5742ff636cfd9558bb5879895d8e956ab5ff6c5a625fb06a6aba626bcabc78c49b6cb1 +burn_alpha 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +cross_dissolve 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d +cross_dissolve 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b +cross_dissolve 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8 +dip_to_black 0.25 2e12104512105b12107112102e1b10451b105b1b10711b102e25104525105b25107125102e2e10452e105b2e10712e10 +dip_to_black 0.50 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +dip_to_black 0.75 061d3b061d3b11273b061d3b06234b06234b4d694b06234b06295b06295b1d415b06295b06306b06306b06306b06306b +dip_to_color 0.25 8616149d1614b31614c91614861f149d1f14b31f14c91f148629149d2914b32914c929148632149d3214b33214c93214 +dip_to_color 0.50 b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808b00808 +dip_to_color 0.75 5f213f5f213f692c3f5f213f5f274f5f274fa56e4f5f274f5f2e5f5f2e5f76455f5f2e5f5f346f5f346f5f346f5f346f +dip_to_white 0.25 ae918fc4918fda918ff1918fae9b8fc49b8fda9b8ff19b8faea48fc4a48fdaa48ff1a48faeae8fc4ae8fdaae8ff1ae8f +dip_to_white 0.50 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +dip_to_white 0.75 869cba869cba90a7ba869cba86a3ca86a3cacce9ca86a3ca86a9da86a9da9dc0da86a9da86afea86afea86afea86afea +film_dissolve 0.25 41282f5c282f7f2c2f91282f413a335c3a33ad5333913a33414c375c4c37895437914c37415d3b5c5d3b775d3b915d3b +film_dissolve 0.50 2b2d42392d42553642532d422a3e4d393e4da5764d533e4d2a4e58394e58656158534e582a5e62395e62465e62535e62 +film_dissolve 0.75 1933591e335936415928335919426e1e426e9fa16e28426e1950821e50824c7082285082195f961e5f96235f96285f96 +luma_fade 0.25 5c2320892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e05c22 +luma_fade 0.50 472835462f4c4547621a38702540681d438199cb8f0e46951352a80e53b43b81b60d53b60d60d40d60d60d60d60d60d6 +luma_fade 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +morph_cut 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d +morph_cut 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b +morph_cut 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8 +mosaic_transition 0.25 7f3633973633cb3633f336337f3633973633cb3633f336337f3633973633cb3633f3363359650d70650da4650dcd650d +mosaic_transition 0.50 0d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b2036566f36566f774e77774e77 +mosaic_transition 0.75 0d46963d7696a6df964680960d46963d7696a6df964680960d46963d7696a6df964680960d66e50d66e50d66e50d66e5 +chaos 0.25 5f23238f2323b62320e423215f3621893620b83520e635225f492089491eb4491ee348225c5c1f895c20b45c1fe35c21 +chaos 0.50 452b383e31537437476a304d343d5446405dac98648e3c533d4c5a4e4d606d6f7b8d4d5e315e8d5f5e623a5fa97c5e78 +chaos 0.75 0d39760d3976224f760d3a770d46950e46969ad3950d46960d53b50d53b63a81b50d53b50d5fd50d60d60d60d60d60d6 +earthquake 0.25 6632218f3221b93221e3322064441f8e441fb8441fe244206656218f5621b95620e2562064641f8d641fb8641fe2641f +earthquake 0.50 412a47582d476c2e47782c444036526a4d538755557f39574044626a5b638b69637b48633f53705657716f5c727e5675 +earthquake 0.75 0d418a0d418a4478893d71880d4da80d4da75191a73272a60d59c60d59c51d69c50e5ac40d63de0d63de0d63dd0d63dd +flicker 0.25 4f1e1b751e1b9c1e1bc21e1b4f2e1b752e1b9c2e1bc22e1b4f3f1b753f1b9c3f1bc23f1b4f4f1b754f1b9c4f1bc24f1b +flicker 0.50 0a2d5c0a2d5c1a3d5c0a2d5c0a36740a367477a4740a36740a408d0a408d2e648d0a408d0a4aa60a4aa60a4aa60a4aa6 +flicker 0.75 0c346c0c346c1f486c0c346c0c40890c40898cc0890c40890c4ca60c4ca63676a60c4ca60c57c30c57c30c57c30c57c3 +glass 0.25 602132782121a32722d82d2a423513783521a63521fc3e2d59482b784821a84a23fa5225645f247e5f25af6225f96523 +glass 0.50 352e563e2e4d5b354f6f3e5f243e573e3e5e78625e466097314e753e4e6f6763732961be5c614c47627f4e6596226bdf +glass 0.75 0d39750d397518447a1a48850d46950d46955f9895316fa60d53b50d53b5387eb71562ca1564d51163d91166de0e69eb +glitch 0.25 5b2321872320b32420e0232059391f863620b33722de38215e4b22884b20b44b20dc4c215a5c1e875e20b45c20dd5f1f +glitch 0.50 0d3976153d76214d750f3c742c44623f4661a194645f4165464c55625253824c55984d534f5d3c765d3e9b5e3bba623a +glitch 0.75 0d3976123e732650740d3d730e479514509292ce940d46961255af0d53b63d7fae1057af1161d0125ed30d61d3135ed2 +grunge 0.25 5c2320892320b62320e223205c3620893620b63620e236202b42637f4528b64920e249200d5ccd5e514ab65c20e25c20 +grunge 0.50 163265612224b62320e223200e428a813422b63620e236200d52b32b4477a14226e249200d60d61059c08b5139e15c20 +grunge 0.75 0d39760d39752e355828294d0d46960d4695737f580d46960d53b60d53b63477ae0d4da70d60d60d60d60d60d60d5bcb +kaleidoscope 0.25 d4501bb64714b74615d2521adb4425b74422b74523d24d1ed24d1eb74523b74422db4425d2521ab74615b64714d4501b +kaleidoscope 0.50 74506e816c62846f63734f6c6a536ca08869aa9067794f6a794f6aaa9067a088696a536c734f6c846f63816c6274506e +kaleidoscope 0.75 0d51b04489b34488b20d51b21056b49fe1ab8acdad0d58c20d58c28acdad9fe1ab1056b40d51b24488b24489b30d51b0 +liquid_distortion 0.25 6b22239e2220d1261ef3281c6c331e9d3820d03a22f23c1f684520994f20cb4f20f24d21655b2091611fc56121ed5f20 +liquid_distortion 0.50 483653906f568f5958772f47444261736363956e5d813b5d3e4e6a53536d8165667946633d5d7b4d5d7967587771566e +liquid_distortion 0.75 0d3c7c26609787c39b0d45930d4a9f0d52b23379b40d4eaa0d59c50d5dcf0d5ccc0d57c10d65e20d65e40d65e30d62db +tv_power 0.25 1014171014171014171014176a818f6e818f73818f77818f6a858f6e858f73858f77858f101417101417101417101417 +tv_power 0.50 0202030b0e100b0e10020203030404232c32232c32030404030404232c32232c320304040202030b0e100b0e10020203 +tv_power 0.75 1014171014171014171014176283996283996a8b996283996286a06286a06488a06286a0101417101417101417101417 +vhs_damage 0.25 5325237927259f2927c42b295637257d3a28a13b29c73e2c5949287f4c2aa44e2dca502f696939906c3cb36c3bda6f3f +vhs_damage 0.50 342f45473147633c4a6a324840475d52495ea88e6e764b6043566d55586e7c6e7377576d3d5c724f5d73615f75736076 +vhs_damage 0.75 224676214575385c7b234677174484184584a2d0a01745851a52a01951a0477fa91951a01c5ebc1c5ebc1c5ebc1c5ebc +vr_chroma_leaks 0.25 a45a37ed8d62ff6681ff395cb87647ffb57dff90bfff5996a77546ec9a62ff88aeff6aa1866f38c0793eff7672ff6f74 +vr_chroma_leaks 0.50 8650a1a86d6ad9a57fcd88c6c973fec78892ffffa4ffb7ffe07cffb983a6dab79eff9effb677ea9075a3937c92dd7cd1 +vr_chroma_leaks 0.75 4b86d85460ce798497668a926dacff8f81ffffffd177a3bd6c94ffa282ffafb0f85b8fd34e77ff7e77ff5b75ff3477e5 +vr_gradient_wipe 0.25 2533591e37694336592a37695c3620843724b53620da37245c4920894920b64920e249205c5c20895c20b65c20e25c20 +vr_gradient_wipe 0.50 0d39760d3976224f760d39761844841146919dbd841446915a4a25784b33b34d25c54b335c5c20895c20b65c20e25c20 +vr_gradient_wipe 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46961053b00d53b54181b00d53b5525d37635e549f5d37a15e54 +vr_iris_wipe 0.25 5c2320892320b62320e223205c3620713934b65235e236205c4920754b3ba35535e249205c5c20895c20b65c20e25c20 +vr_iris_wipe 0.50 52262b1c366d374c6bc726295937230f46929ed193dd37245a4a241053b14081b1dc4a24515d381b5fbe235fbec85d37 +vr_iris_wipe 0.75 0d39760d3976224f760d39761744800d46969ad3963444821a51a10d53b63b81b630519b0d60d60d60d60d60d60d60d6 +vr_light_leaks 0.25 ac6149ff865bff7549ff5b3dc58653ffb267ffb960ff9d55b38947ffa353ffc55cffba598c7e33c98737ffab45ffa845 +vr_light_leaks 0.50 a97971af7c7dfaa18cff917bffc7a1d6a69bffffa8ffd5a4ffe3b5c8a89ff4bda0ffd5acd4c3ab979297998692f8aea1 +vr_light_leaks 0.75 90869c7f80998096a3758aabd0b9ced7c5d5ffffd18ba5d3adb5e6dfd8f8bddce86796e06396f099b9ff6298f3377ee8 +vr_light_rays 0.25 6b322da9423cd94540f3332f744d35dd886cff876af1442d6b572ddc9a6aff9b6cfa60356d6c2fad7f40d57b3cf16b2d +vr_light_rays 0.50 413a567a5c75986372863c58464f6ba99ab0ffdfaf864d68445d78a8a8bed7c1c0895f7b436c877789a2908ca5846a86 +vr_light_rays 0.75 1a4782345f993f6b90184480235caa6399e3e7ffdc2058a72065c75a9efb91d4ff2369ca186ae02a7cf03485f91a6de2 +vr_mobius_zoom 0.25 841b1ec03026bf4a1ee244267f1e1caf3522a14f1bc5581a7628229b3021924b20bb61206a3c277a351c865020bb6521 +vr_mobius_zoom 0.50 523e665d46696648698a6b5253426f5f4a6b54495f89794f5145738d7966686454a194554e4a768e7a6e949255a2965f +vr_mobius_zoom 0.75 0d4fad0d54b70d428b0d34680d5ccd0d57c00d45940d366e0d63dd5198b85a8f8c0d3d7e0d65e3357fc18bc3940e4b9f +vr_random_blocks 0.25 5c2320892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e25c20 +vr_random_blocks 0.50 0d39760d39766b3a43e223200d46960d4696b58760e236200d53b60d53b67c6768e249200d60d60d60d6685e7ce25c20 +vr_random_blocks 0.75 0d39760d39763449694334600d46960d4696a1c0884342780d53b60d53b64b7aa24350900d60d60d60d6245fbf435fa8 +vr_spherical_blur 0.25 672d228a2d20b52d1fd82d1e6638218a3820b53820d8381f6647218a4720b54720d9471f6653218a5320b55320d8531f +vr_spherical_blur 0.50 3d3a56523f556a4255773f543d425e52475e6a4a5d77475d3c4d695151686853687651673c5572505871665a71755870 +vr_spherical_blur 0.75 0f42861a4d862457861a4d860f4a991b56992560991a55990e53b2185db22166b2185db20e5ac61460c61966c61460c6 +burn_chroma 0.25 de475dff495eff4b5fff4e61e36a5fff6d60ff6f61ff7162e88e61ff9062ff9363ff9564edb262ffb463ffb664ffb966 +burn_chroma 0.50 7090d89891d9d7b3dbe994da75c0ff9ec2ffffffffefc4ff7bf0ffa3f2fffdfffff4f5ff80ffffa9ffffd1fffff9ffff +burn_chroma 0.75 2faacd2faacd66e9d02faacd33d0ff33d0ffffffff33d0ff38f5ff38f5ffaeffff38f5ff3cffff3cffff3cffff3cffff +chroma_leak 0.25 ff8764ffa886f18982f04241ffa16fffc18ef69f85f15642ffa26bffb376ed9567ef6037e2975ce99b55dc8444eb682b +chroma_leak 0.50 a7587ded8982ffb890ccb2b7c36e9effa1a1ffffadd5d0d8c97ab5ffa3b6ffd7b5c8c1cdb880c0e299bed5a6b0af9fb1 +chroma_leak 0.75 38428f59519b8e89998493954951b97263c9ffffc693a8be545fe07f6ff3c2baed89a5df546bff7b77ff8289ff6b96f8 +cross_zoom 0.25 6f2c1e932c23ab2c1cd02c22713920933923ac391dcd391f714620884618b74628cd4620725421975427a75418cd541f +cross_zoom 0.50 503f605d40659c8b53a88c5849445d4e445ba78c66af906448486149485c947a6eb19569484d664a4d616a5573927b6e +cross_zoom 0.75 0d3f850d3f8572a5852e60850d489b0d499ba4df9b4a869b0d51b10d51b15ca0b12267b10d5ac70d5ac70d5ac70d5ac7 +directional_blur_transition 0.25 7d23278e23239f2320b0231d71361b88361d9f3620b636227949238c49219f4920b2491e755c1f8a5c1f9f5c20b45c20 +directional_blur_transition 0.50 432f4f4b2f4d522f4c592f4b4245584c455956455a5f455b42506c4a506c52506b5a506b3e5e7a475e7a4f5e7b585e7b +directional_blur_transition 0.75 0f3c760f3c760f3c760f3c761d56961d56961d56961d56961258b61258b61258b61258b60d60d60d60d60d60d60d60d6 +flare 0.25 8e412db83f2cba2521e22320b76d38fc7c3ebd3a22e236207e5e29af602ac95525ea4e22646122916122b75d20e45e20 +flare 0.50 3a324c653e528649527a2f4b40455eb57e77ffba727d415c3a516ca48482e3a58783556e375f7b656e827c6e827d617c +flare 0.75 0f3b760e3a762a5378143e78144b9820529bbfeaa02f5b9f0d53b61457b7aec7d4678ace0d60d61162d73c7ce23e7de3 +flash 0.25 5f26238b2623b72623e326235f39238b3923b73923e339235f4c238b4c23b74c23e34c235f5f238b5f23b75f23e35f23 +flash 0.50 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +flash 0.75 103c78103c78255178103c781049971049979bd3971049971055b71055b73e83b71055b71062d61062d61062d61062d6 +glow 0.25 e45b4eff5b4bff5b4bff5b48e2814cff814bff814bff814ae2ab4cffab4bffab4bffab4ae2d04cffd04bffd04bffd04a +glow 0.50 9082cdcd8bccffa2ccff8aca8fa4eecfafeeffffeeffafed8ecaffccd3fffff7ffffd3ff8eecffc7f0fffff5fffff0ff +glow 0.75 1f8eff2f9dff59c8ff2e9cff1fa7ff33baffdaffff31b9ff1fc3ff2ed2ff71ffff2dd1ff1edbff25e2ff2eebff25e1ff +lens_blur 0.25 5f2422892420b62420e0241e5c361f893620b63620e336215d4920894920b64920e1491f5d5c20895c20b65c20e25c20 +lens_blur 0.50 37314e4f354d71414d7a354c363e5b54475a77545a7d455a354d6a50536a74606a7b526a375b794c5c78676178765c77 +lens_blur 0.75 0d3a770e3b772a57770e3b770d469516509575ae95144e950d53b51056b53d83b50f55b50d5fd40d5fd40d60d40d5fd4 +light_leak 0.25 da5739b53225c12621e42420ee703abf4825c43a21e53720e77e36c15b25c54e21e54a20cb842fb96b23c36020e55d20 +light_leak 0.50 ffc9a3ff8277a4505583324cffeab8ff9d89ee9f6686435cffe3b9ffa392bf7f7587536cffc6aeec9b959d728286627c +light_leak 0.75 a494b1ffbac5e5a9a9505785c4acd5ffd8eaffffcc6269a7c1aeeaffd5fcffdfe36073c4a0a3f9e6bdffb8a3f44d77df +light_sweep 0.25 94574c932c28b72421e22320a77c5b98442bb73821e23620bea46d9e5d31b84c22e34920d9d182a87938ba6023e35d20 +light_sweep 0.50 ffffffffffffffffffffbba0f9ffffffffffffffffffeebcd3ffffffffffffffffffffdcb0f7ffffffffffffffffffff +light_sweep 0.75 0d3a76113d79416b8e89aed80d46960f4998afe7a76ea1e30d53b60e54b74a8fc15899f10d60d60e61d61769dd4594ff +phosphor 0.25 645333915b36be6439e96b3b677139947a3cc88a3eec8a416a903f979841c6a344efa8466eae449ab647c6be49f2c64c +phosphor 0.50 3a4351514753735554804e553b5863535c64b0a6658163673d6d75547176828c778378783e82865586876d8989848d8a +phosphor 0.75 133d73153e732c53731b4074134c91164d929dd3921b4e92135ab0165bb04488b01b5db11469ce1669cf196acf1c6bcf +radial_blur 0.25 5c251e89241fb62420e224215f36218a3621b4361fe0361f5d491f89491fb64920e249215d5b20895c20b65c20e15b20 +radial_blur 0.50 34304b4b2f4b6d3b4c77304e363f5b4d405ba3815b763e5b354e694b4e6a79656b784e6c365c784b5d7a615d7a775c7a +radial_blur 0.75 0d3a780d3a772451770d3a790d46960f499694cd960d46960d53b50d53b53d83b60d53b60d5fd30d5fd40d5fd50d5fd3 +ray 0.25 5c23208d2421be2522ff2926603821993c24d64027ff462a6c5226a65826f7632eff682f7c752ac67f30ff842eff8e33 +ray 0.50 352e4b4b2e4bc7807798455c353f5c4f415fffffc4c274853d5878585c7deec9baae779647789e697fa88782aaad89b3 +ray 0.75 0d39760d3a76a4fae1336b960d46960e4797ffffff57abdb0e5ac50e5ac5baffff287fe01074ff1074ff1074ff1074ff +solarize 0.25 808d81448d812b8d81258d8189cd8149cd812ecd8127cd8183a98146a9812ca98126a981867e81477e812d7e81267e81 +solarize 0.50 e4d4b7b6d4b76bbcb74ed4b7c6e389b9e3891d428950e389daab66b7ab665c7f664fab66d0804ab8804a7b804a50804a +solarize 0.75 33d35733d35733bd5733d35733b13a33b13a30303a33b13a33912a33912a326e2a33912a337725337725337725337725 +stripe 0.25 855a5b902b27b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e25c20 +stripe 0.50 0d39760d3976224f76113b780d46960d4696aad498d385981c5db97888b2da8053e950268e9a7d94682db65c20e25c20 +stripe 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +zoom_blur 0.25 64271f8b271fb42721db27216637218d3721b2371fd8371f63481e8a481eb54822dc48226759228e5922b1591ed7591e +zoom_blur 0.50 39324e4c324e7c4f4f75354f3a3f5d4e405da98a5b7d4b5b394d694b4d68806c6b764f6b3b5a784e5a795e5a75715a76 +zoom_blur 0.75 0d3c7c0d3c7c3e6e7c0f3e7c0d47980d4798a2dc981e58980d52b40d52b4488eb40f55b40d5dcf0d5dcf0d5dcf0d5dcf +roll_3d 0.25 311215641c188a1b18a519173322117f3521b03521d4321f382f157d4a1faf4a1fd4461e2d3111664c1a8c4a19a64518 +roll_3d 0.50 0820400b294e4a171967131223527b34637c562d189a2c1b114d9b18539a593e1c9a3f1b07367a094191463f15673512 +roll_3d 0.75 092a560a2d5d13375e071e3d0c418c235c9471aa94082c5e0c4fad1057b72f75b708357509479f0a4dab0a4eae073271 +film_roll 0.25 3e181a7a2320a72320d323203625137a3620a73620d336203c32187a4920a74920d34920383e157a5c20a75c20d35c20 +film_roll 0.50 1440760a1b322813156d23203f7896152b482c25196d36201d63b6152f54312c1e6d49200d60d60a2a562428116d5c20 +film_roll 0.75 0d3976123e761d4a7609264f0d46963c76966aa3960a30640d53b61960b62e74b60a38790d60d60d60d60d60d609408e +push 0.25 4729397e2320aa2320d72320413a3b7e3620aa3620d73620454b477e4920aa4920d74920435d4d7e5c20aa5c20d75c20 +push 0.50 204d7615386e58252a89232080b9961e508956382b893620367cb61654a7584a308949200d60d6135fc3575c32895c20 +push 0.75 0d39760f3c76204c762033630d4696316a9676af961a42750d53b6145bb63379b61e50910d60d60d60d60d60d61c5ea6 +roll 0.25 3a19187a2520a0211fb21d1b3b28157b3820a83520d433203f38177d4b20aa4920d64620374d4276571eab5c20d85920 +roll 0.50 0b306204142b180e09521d193b728f082e643727187232215c9eab0f51ab4a4c257f4321115ec7093c843f3b1989541f +roll 0.75 0b3062113c71214f780827510d44913972956ea99809326c0d51b1165cb42d74b8093e890d5ed10d5fd40c59c7174e8f +slide 0.25 482932892320b62320e223204f3a41893620b63620e236204b4c44894920b64920e249204d5d4f895c20b65c20e25c20 +slide 0.50 204d761a386ca42527e2232080b99628508ba7382de23620367cb61d54a6a54a2fe249200d60d61b5fc3a65d33e25c20 +slide 0.75 0d39760f3c76204c7644345c0d4696316a9676af964a427a0d53b6145bb63379b646508e0d60d60d60d60d60d6485fa8 +split 0.25 6223207a2629a0262ddd23206236207d3830ad492edd36206249207b4b32a04c34dd4920625c207c5d379d5d37dd5c20 +split 0.50 8124241338712a4d72af24258237261545909dce8fae3725814a271452ae4380aeaf4a28815d29145fcc145fccae5d29 +split 0.75 1d37690d3976224f7622376d2044880d46969ad3961f44861e52a20d53b63b81b62152a31f5fbf0d60d60d60d6205fbe +stretch 0.25 492a3881231fb0231fe0231f4d443b823621b23621e136214a4f4781491fb1491fe0491f455d4d825c20b15c20e15c20 +stretch 0.50 0d3976174476732320cc23200d4696538d96733620cc36200d53b6246ab6734920cc49200d60d60d60d6735c20cc5c20 +stretch 0.75 0d39760d3976214d763134600d46961d569680ba963142780d53b60e54b6377db63150900d60d60d60d60d60d6315fa8 +whip 0.25 502733842320b12320da231e4c3935843620b13620de36224f4b3e844920b14920db491f4d5d42845c20b15c20dc5c20 +whip 0.50 1d4a761b386b57262f83231c7fb8962857825439308b36233076b61d57a0564b3786491e0d60d6175fba555d3b885c20 +whip 0.75 0d3976103d761e4b761b35680d469628629675ae961f4c7d0d53b6155bb63177b61b529a0d60d60d60d60d60d6185fb2 +motion_camera 0.25 62261f8a261fb42621dd26216337218c3721b3371fdb371f61491f8a481fb54821dd4821645a218c5a21b35a1fdb5a1f +motion_camera 0.50 3b334f4d334f8357507536513c405d4f405daa8c5c7f4f5c3a4d674b4d67816d6b754f6b3d59775059785d59746f5974 +motion_camera 0.75 0d3b7a0d3b7a35637a0d3c7a0d47970d4797a1db971650970d52b40d52b4448ab40d53b40d5ed10d5ed10d5ed10d5ed1 +motion_tween 0.25 582525812525ab2525d52525583727823727b23e27d43727584a29814a29ad4c29d44a29585d2c825d2cab5d2cd45d2c +motion_tween 0.50 362f494b2f4966344a762f4a373e5b4c3e5ba07e5a753e5a374e6b4b4e6b75626c764e6c375e7d4c5e7d605e7c755e7c +motion_tween 0.75 1238701538702a4b701a387012458e15458e99c88e19458e1252ac1552ac427dad1a52ad125fcb155fcb175fcb195fcb +shape_dissolve 0.25 5c2320892320b62320e223205c36207b3831b43f26e236205c49201e51a46a6879e249205b5c220e60d33c5fa8e25c20 +shape_dissolve 0.50 492838123874394b69d325271942760d46969ad396803e610e53b30d53b63b81b649508c0d60d60d60d60d60d6475fa9 +shape_dissolve 0.75 0d39760d3976224f7643345d0d46960d46969ad39649427b0d53b60d53b63b81b62c519f0d60d60d60d60d60d6205fc6 +shape_flow 0.25 5c2320892320b62320e2232056372b893620b63620e23620574a2d894920b64920e249205c5c20895c20b65c20e25c20 +shape_flow 0.50 3e2b470d3976604652e223201f416b0d4696a6cc8de236202450850d53b64b80a8e24920375e610d60d64f5f90e25c20 +shape_flow 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +text_animator 0.25 332839782222b52320e223203f353c833523b63620e236204e452a884920b64920e24920565928895c20b65c20e25c20 +text_animator 0.50 0d397614366c4d3348ac23280d46941a3f7e96734ec334291050ab304774905239da48211259b93e5372955634e15c20 +text_animator 0.75 0d39760d3976224f761039730d46960d469699d39615428a0d53b60d53b63c80b32e4a8c0d60d60d60d6105cc9415592 +typewriter 0.25 0d39760d3976224f760d3976623c26893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e25c20 +typewriter 0.50 0d39760d3976224f760d39760d46960d46969ad3960d4696614e25894920b64920e249205c5c20895c20b65c20e25c20 +typewriter 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b6626126895c20b65c20e25c20 +spin_3d 0.25 2d10125817147d1617580d0a3a2414853421b7341e8c1f153c3316834a1fb94a208a2d132b2d105a40157b3e155a250c +spin_3d 0.50 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +spin_3d 0.75 05162c08254c0c294e061b380829580d44917ab291092f6408336f0d53b72d73b709397d05265408408f094293062f68 +spinback_3d 0.25 210c0e4b13106f1315510c09321f11843321b8331e8c1f15332c13824b1fba4b20872c121c1e0a463411663412481e09 +spinback_3d 0.50 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +spinback_3d 0.75 041123071e3d071e3d0411230728550d449070a79007285508326f0d54b82970b808326f05234e07397f07397f05234e +frame 0.25 ece7e9d9c0c0f0c0c2f5e7e6cbc3bf7d3120c2311ff4c3c0cbcbbf7d4f20c24f1ff4cbc0ecece9d9d3c0f0d3c2f5ece6 +frame 0.50 e5e9eec9ddf8c9ddf8e5e9ee8585858e8e8e8e8e8e8585858585858e8e8e8e8e8e858585e8e6e7e1cdccf3cdceeee6e5 +frame 0.75 e6e9edbcc7d6bcc7d6e6e9edbac8da0d428c4d838cbac8dabacde70d57bf1f69bfbacde7e6ecf5bcd4f6bcd4f6e6ecf5 +louver 0.25 401a16601a167f1a169e1a165c3620883620b53620e136205c4920894920b64920e249205c5c20895c20b65c20e25c20 +louver 0.50 0d39750d3975224e750d39750930670930676a91670930671512071f12072912073312075a5a1f855a1fb05a1fdb5a1f +louver 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60c5dcf0c5dcf0c5dcf0c5dcf +mirror_transition 0.25 592423892320b62320e223205a3726893620b63620e23620594a27894920b64920e24920595d29895c20b65c20e25c20 +mirror_transition 0.50 0d39760d39766c2d43e023210d46960d4696703d55e036210d53b60d53b66d4d60e049220d60d60d60d66e5e70e05c22 +mirror_transition 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +page_peel 0.25 5c2320892320b62320d421205c3620893620b53620c4311f5c4920894920b3481fb43f235c5c20895c20ae591f904830 +page_peel 0.50 5c232083211f981e1c3520385c36207c321c892d190e2b595b481f79401d663b3508367658591f664a25253b63094191 +page_peel 0.75 b787785655681736510a2c5b896e571534616d966b0a377645453e0835752b5f860a4393173b620941910a49a30b4fb1 +slice 0.25 5a2424852323b42421be272f5b36208a3620b4361fcf372b5c4920894920b64920de4a245c5c20895c20b65c20df5c22 +slice 0.50 5f313743324e75334d0d3a77363d4d973a20b135217c3345594d37894824b0481f594d7f475c44875c27965e449c584b +slice 0.75 433f640d3a772451770d3a772d49810d46969cd5960d46964859891453b13d81b21050ac0d5fd50e5fd2135dc53d4560 +wave 0.25 33274d711e22b1231de223202a3451743121b63621e2362030486879461eb4491fe24920305c74865b22b65c20e25c20 +wave 0.50 0d397514356b992023dc221d0d4593123f81882e25df35240d51b2104a9a7d3e29d6471d0d5ed11156b6744f28d85a21 +wave 0.75 0d39760d3a77245480893a5d0d46961852965c989d7c475d0d53b60d53b6246cba8653740d60d60d60d60d60d6805f7e +clock_wipe 0.25 5c2320892320224f760d39765c36208936209dc0846141665c4920894920b64920e249205c5c20895c20b65c20e25c20 +clock_wipe 0.50 5c2320892320224f760d39765c36208936209ad3960d46965c49208949203c81b40d53b65c5c20895c201160d00d60d6 +clock_wipe 0.75 5c2320892320224f760d39764e39388137279ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +linear_wipe 0.25 12386f892320b62320e2232014458f893620b63620e236201352ac894920b64920e24920135fca895c20b65c20e25c20 +linear_wipe 0.50 0d39760d3976b62320e223200d46960d4696b63620e236200d53b60d53b6b64920e249200d60d60d60d6b65c20e25c20 +linear_wipe 0.75 0d39760d3976224f76d524240d46960d46969ad396d737280d53b60d53b63b81b6d64a290d60d60d60d60d60d6d65d2c +neon_wipe 0.25 2fd3ff8b515bb62c2ce2252238e2ff8b645bb6402ce2382232f1ff8b785bb6532ce24b2235ffff8b8b5bb6662ce25e22 +neon_wipe 0.50 0d4a8a11a4feba8ea8e333340d56aa11b1ffbaa1a8e346340d63ca11beffbab4a8e359340d70ea11caffbac7a8e36d34 +neon_wipe 0.75 0d3b780d4382247db1a0ccff0d48980d4fa29bffd1a8ddff0d55b80d5cc23daff0a3eeff0d62d80d69e10f8effa5ffff +panel_wipe 0.25 0d3976632a32b62320e22320264279883621b63620e23620504b32894920b64920e249205c5c20895c20b65c20e25c20 +panel_wipe 0.50 0d39760d3976344668e223200d46960d4696b56b49e236200d53b63a5083b64920e249200d60d67d5d39b65c20e25c20 +panel_wipe 0.75 0d39760d3976224f760d39760d46960d46969ad3963044850d53b60d53b63d81b4ac4c3f0d60d60d60d6405f9ee25c20 +plateau_wipe 0.25 322e46892320b62320e22320204386883621b63620e236201d519b884921b64920e24920395e7c895c20b65c20e25c20 +plateau_wipe 0.50 0d397628345fb52320e223200d46960e4695b5492fe236200d53b60e53b4ab542ee249200d60d62b5facb55c20e25c20 +plateau_wipe 0.75 0d39760d3976244e74d524260d46960d46969ad396983c510d53b60d53b63b81b6934d550d60d60d60d60f60d3d65d2d +radial_wipe 0.25 422b3d15386e224f760d39765c36207b38279b9e6b0d46965c4920894920b64920984d555c5c20895c20b65c20e25c20 +radial_wipe 0.50 22335f0d3976224f760d397649392f0e46949ad3960d46965c49203c4f793b81b60d53b65c5c207b5d2d1660cc0d60d6 +radial_wipe 0.75 14376d0d3976224f760d39761f416d0d46969ad3960d4696354e690d53b63b81b60d53b6425d4c0d60d60d60d60d60d6 +soft_wipe 0.25 5c2321892320b62320e223205c3621893620b63620e236205c4921894920b64920e249205c5c22895c20b65c20e25c20 +soft_wipe 0.50 22335e413051783543a92937234276423f65ab7952aa3a4023508d414f7683615fa94c48235fa5425e896f5e6ca95d51 +soft_wipe 0.75 0d39760d3976224f760f39750d46960d46969ad3960f46950d53b60d53b63b81b60f53b40d60d60d60d60d60d60f60d4 +star_wipe 0.25 5c23207b252ba62628e223205c362033406daca170e236205c4920464e717a696be249205c5c20785d36a15d37e25c20 +star_wipe 0.50 3e2b452a3469474960902b441f42700d46969ad396514273534b330d53b63b81b6c94b2f515d370e60d40e60d4ca5d36 +star_wipe 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46961a51910d53b63b81b6415194255e8e0d60d60d60d6665e91 +stretch_wipe 0.25 2b31545a2c4099272edb24222d40695b3c4e9a3934dc37242c4f7c5a4d59994b39db4a252c5e905b5e659a5d3fdc5d26 +stretch_wipe 0.50 0d39760d39761139742c36690d46960d46963f7393556c850d53b60d53b61153b22d519f0d60d60d60d61160d12d5fbb +stretch_wipe 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +additive_dissolve_legacy 0.25 63405b8f405bc54a5be8405b63596b8f596bf3a06be6596b63737b8f737bd08a7be7737b638c8b8f8c8bbc8c8be68c8b +additive_dissolve_legacy 0.50 695c96965c96cb7196ee5c96697cb6967cb6fbf4b6e97cb6699cd6969cd6d5bdd6ed9cd669bcf096bcf0c2bcf0ebbcf0 +additive_dissolve_legacy 0.75 3b4b86514b867c60867e4b863b61a65161a6eeeda67e61a63b78c65178c695a2c67e78c63b8ee5518ee5688ee57e8ee5 +barn_doors 0.25 5c2320532d4c73363fe223205c3620483d4fb67d58e236205c4920504e6582625fe249205c5c204c5e6f725e71e25c20 +barn_doors 0.50 5c23200d3976224f76e223205c36200d46969ad396e236205c49200d53b63b81b6e249205c5c200d60d60d60d6e25c20 +barn_doors 0.75 3130570d3976224f766a304a263f5d0d46969ad396753f672d4f770d53b63b81b66d4f71295e850d60d60d60d6715e87 +center_split 0.25 732d2053334d653f49cc2d2040425f2f447ba0b17a6c425f404a672a4e89677d936c4a67735320575981555974cc5320 +center_split 0.50 8936200d3976224f76b636200d46960d46969ad3960d46960d53b60d53b63b81b60d53b68949200d60d60d60d6b64920 +center_split 0.75 2f3b630d3976224f76343b620d46960d46969ad3960d46960d53b60d53b63b81b60d53b62a58a10d60d60d60d63958ab +clock_wipe_legacy 0.25 5c2320892320224f760d39765c36208936209ad3960e46955c4920894920b64920e149215c5c20895c20b65c20e25c20 +clock_wipe_legacy 0.50 5c2320892320224f760d39765c36208936209ad3960d46965c49208949203b81b60d53b65c5c20895c200d60d60d60d6 +clock_wipe_legacy 0.75 5c2320892320224f760d39765c36208936209ad3960d46960d53b50d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +cross_dissolve_legacy 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d +cross_dissolve_legacy 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b +cross_dissolve_legacy 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8 +cross_zoom_legacy 0.25 7b3416a9343296340dc43429863c21943c1eaa3c22b93c1f8e442984440dbb4432b14416884b23904b1aaf4b26b74b1d +cross_zoom_legacy 0.50 0d489a104b9aa3de9aa6e19a0d4ba20f4da29bd9a2a6e4a20d4eaa0d4eaa6daeaaa6e7aa0d51b20d51b22065b28bcfb2 +cross_zoom_legacy 0.75 0d45930e4793a2da93a6de930d4a9f0e4b9fa1de9fa6e39f0d4fac0d4fac73b5ac9fe1ac0d54b90d54b9125ab91e66b9 +dip_to_black_legacy 0.25 2e12104512105b12107112102e1b10451b105b1b10711b102e25104525105b25107125102e2e10452e105b2e10712e10 +dip_to_black_legacy 0.50 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +dip_to_black_legacy 0.75 061d3b061d3b11273b061d3b06234b06234b4d694b06234b06295b06295b1d415b06295b06306b06306b06306b06306b +dip_to_white_legacy 0.25 ae918fc4918fda918ff1918fae9b8fc49b8fda9b8ff19b8faea48fc4a48fdaa48ff1a48faeae8fc4ae8fdaae8ff1ae8f +dip_to_white_legacy 0.50 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +dip_to_white_legacy 0.75 869cba869cba90a7ba869cba86a3ca86a3cacce9ca86a3ca86a9da86a9da9dc0da86a9da86afea86afea86afea86afea +film_dissolve_legacy 0.25 41282f5c282f7f2c2f91282f413a335c3a33ad5333913a33414c375c4c37895437914c37415d3b5c5d3b775d3b915d3b +film_dissolve_legacy 0.50 2b2d42392d42553642532d422a3e4d393e4da5764d533e4d2a4e58394e58656158534e582a5e62395e62465e62535e62 +film_dissolve_legacy 0.75 1933591e335936415928335919426e1e426e9fa16e28426e1950821e50824c7082285082195f961e5f96235f96285f96 +inset 0.25 16376a892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e25c20 +inset 0.50 0d39760d3976b62320e223200d46960d4696b63620e236205c4920894920b64920e249205c5c20895c20b65c20e25c20 +inset 0.75 0d39760d3976224f76d524240d46960d46969ad396d737280d53b60d53b63b81b6d64a29585d29825d29ac5d29e25c21 +iris_box 0.25 5c2320892320b62320e223205c36206e3939b45639e236205c4920724c3fa75f38e249205c5c20895c20b65c20e25c20 +iris_box 0.50 5c2320892320b62320e223205c36200d46969ad396e236205c49200d53b63b81b6e249205c5c20895c20b65c20e25c20 +iris_box 0.75 432a40442f546d4454a02a3a263f5d0d46969ad396753f672d4f770d53b63b81b66d4f713c5e52445f81585f81a85e5b +iris_cross 0.25 5c232067293d913336e22320423a44274174abab7da73a45464d4d2a518a6c7f8da24d465c5c205f5e4c8f5e55e25c20 +iris_cross 0.50 5c23200d3976224f76e223200d46960d46969ad3960d46960d53b60d53b63b81b60d53b65c5c200d60d60d60d6e25c20 +iris_cross 0.75 2134680d3976224f763d345f0d46960d46969ad3960d46960d53b60d53b63b81b60d53b6205fad0d60d60d60d63e5fa6 +iris_diamond 0.25 5c2320892320b62320e223205c36204c3d53b47d59e236205c4920534e6293755be249205c5c20895c20b65c20e25c20 +iris_diamond 0.50 5c23204c2e52764349e223202d3e5a0d46969ad3967f3e61344f6b0d53b63b81b6794f655c5c20465f77665f79e25c20 +iris_diamond 0.75 302f580d3976224f76692f4e0d46960d46969ad3960d46960d53b60d53b63b81b60d53b62c5f840d60d60d60d66d5f84 +iris_round 0.25 5c2320892320b62320e223205c3620543c4ab57350e236205c49205b4d5b8f6551e249205c5c20895c20b65c20e25c20 +iris_round 0.50 5c23202f33654e485ce2232052382b0d46969ad396d0382d544a300d53b63b81b6ce4a2d5c5c202d5fa53b5fa1e25c20 +iris_round 0.75 312f570d3976224f766a2f4a1a42760d46969ad39649427b1e51910d53b63b81b645518f2b5e840d60d60d60d6715e84 +non_additive_dissolve 0.25 5c2320892320a82c24e223205c36208936205c6b48e236205c4920894920985830e249205c5c20895c20b65c20e25c20 +non_additive_dissolve 0.50 16386f762733b43b2ee223203f3f686b3c4aa8d28de236203a4e78874924b17b4ae24920445e76895c20b65c20e25c20 +non_additive_dissolve 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +push_legacy 0.25 0d39765c2320892320b623200d46965c3620893620b636200d53b65c4920894920b649200d60d65c5c20895c20b65c20 +push_legacy 0.50 224f760d39765c23208923209ad3960d46965c36208936203b81b60d53b65c49208949200d60d60d60d65c5c20895c20 +push_legacy 0.75 0d3976224f760d39765c23200d46969ad3960d46965c36200d53b63b81b60d53b65c49200d60d60d60d60d60d65c5c20 +radial_wipe_legacy 0.25 5c2320892320224f760d39765c36208936209ad3960e46955c4920894920b64920e149215c5c20895c20b65c20e25c20 +radial_wipe_legacy 0.50 5c2320892320224f760d39765c36208936209ad3960d46965c49208949203b81b60d53b65c5c20895c200d60d60d60d6 +radial_wipe_legacy 0.75 5c2320892320224f760d39765c36208936209ad3960d46960d53b50d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +slide_legacy 0.25 0d3976892320b62320e223200d4696893620b63620e236200d53b6894920b64920e249200d60d6895c20b65c20e25c20 +slide_legacy 0.50 224f760d3976b62320e223209ad3960d4696b63620e236203b81b60d53b6b64920e249200d60d60d60d6b65c20e25c20 +slide_legacy 0.75 0d3976224f760d3976e223200d46969ad3960d4696e236200d53b63b81b60d53b6e249200d60d60d60d60d60d6e25c20 +split_legacy 0.25 732320492e436f3a52cc2320733620563e60a08756cc36207349204d4e6878676ecc4920735c20525e7c5b5e7acc5c20 +split_legacy 0.50 8923200d3976224f76b623208936200d46969ad396b636208949200d53b63b81b6b64920895c200d60d60d60d6b65c20 +split_legacy 0.75 492e430d3976224f76632e52563e600d46969ad396563e564d4e680d53b63b81b65f4e6e525e7c0d60d60d60d65b5e7a +whip_legacy 0.25 4829397e2320ab2320d72320423a3b7e3620ab3620d73620464c477e4920ab4920d74920445d4d7e5c20ab5c20d75c20 +whip_legacy 0.50 224f760f39735b242388231f97d0961148915a37248a36213b81b60f53b15b4a2588491f0d60d60f60cf5a5d26895c20 +whip_legacy 0.75 0d39760f3c76204c762034640d469630699676b0961a42760d53b6145ab6347ab61e50920d60d60d60d60d60d61c5fa8 +wipe 0.25 0d3976892320b62320e223200d4696893620b63620e236200d53b6894920b64920e249200d60d6895c20b65c20e25c20 +wipe 0.50 0d39760d3976b62320e223200d46960d4696b63620e236200d53b60d53b6b64920e249200d60d60d60d6b65c20e25c20 +wipe 0.75 0d39760d3976224f76e223200d46960d46969ad396e236200d53b60d53b63b81b6e249200d60d60d60d60d60d6e25c20 +band_slide 0.25 422a40892320b62320543356423c44893620b63620544072275080894920b649209b4c55275e9d895c20b65c209b5e59 +band_slide 0.50 573f40602a404533565433566d6844603c44454072544072567f803650807d4c559b4c55275e9d365e9d7d5e599b5e59 +band_slide 0.75 422a40224f760d3976543356423c443871960d4696b5a2722750803b81b60d53b69b4c55275e9d0d60d60d60d69b5e59 +checker_wipe 0.25 4929346a2934953134ad29344c3a3f6e3a3fb35e3fb03a3f474c42684c42915342ab4c424e5d52705d52915d52b35d52 +checker_wipe 0.50 21345f2c345f433f5f42345f24427a2f427aa7af7a46427a1f508d2a508d5e798d40508d265fac315fac3d5fac485fac +checker_wipe 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +cube_spin 0.25 6823249f2320d7231c123e7361361d9f3620de36233069966549219f4920d9491e185fb6635c1f9f5c20db5c200c5dcf +cube_spin 0.50 71231fc9231f0d3975174375733620cc36200d4696538c96734920cc49200d53b6246ab6715b1fc95b1f0d5ed20d5ed2 +cube_spin 0.75 9b221f0d39761a47760f3c769f36200d46965790962c65969f49200d53b6286eb6145ab69b5a1f0d60d60d60d60d60d6 +flip_over 0.25 00000071231fc9231f000000000000733620cc3620000000000000734920cc4920000000000000715b1fc95b1f000000 +flip_over 0.50 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +flip_over 0.75 0000000d39751743750000000000000d4696538c960000000000000d53b6246ab60000000000000d5ed20d5ed2000000 +gradient_wipe 0.25 13386e7a2627b62320e2232033406a893620b63620e236204c4c3d894920b64920e249205c5d23895c20b65c20e25c20 +gradient_wipe 0.50 0d39760d3976553c52e123200d4696104693b55c3ee236200d53b62d5191b34b23e249200d60d5625e62b65c20e25c20 +gradient_wipe 0.75 0d39760d3976224f761139740d46960d46969ad3963f437d0d53b60d53b63c81b5914d560d60d60d60d6225fbed45d2e +page_turn 0.25 5c2320892320b62320e223205c3620893620b63620e136205c4920894920b449206d4a6d5c5c20885c205459780d60d6 +page_turn 0.50 5c2320892320b423206f2c4f5c3620883620957e570d46965c4920444a6d3b81b60d53b62c59780d60d60d60d60d60d6 +page_turn 0.75 5c2320462c4f224f760d3976293b570d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 +venetian_blinds 0.25 422b3c602b3c85333c9b2b3c4f3934743934b25134bf3934424d51604d51905f519b4d514f5d3e745d3e9a5d3ebf5d3e +venetian_blinds 0.50 273259363259584559543259423b47603b47ae6c479b3b47275084365084646f84545084425e5c605e5c7d5e5c9b5e5c +venetian_blinds 0.75 1a36682136683c496830366827416e36416ea39f6e54416e1a519d21519d4e769d30519d275f98365f98455f98545f98 diff --git a/crates/render/src/transitions/grunge.rs b/crates/render/src/transitions/grunge.rs new file mode 100644 index 00000000..da346672 --- /dev/null +++ b/crates/render/src/transitions/grunge.rs @@ -0,0 +1,220 @@ +//! Grunge & Distort: procedural damage (shake, flicker, glitches, glass, liquid) that peaks at the +//! midpoint while the clips swap. + +use std::f32::consts::{PI, TAU}; + +use super::wipe::{WipeStyle, field_wipe}; +use super::{Tx, add_light, bell, fbm, hash, mid_mix, mix, paint, shutter, smoothstep, temporal, vnoise}; +use crate::image::Image; + +/// Per-pixel A/B mix at weight `k`, both sampled (clamped) at (sx, sy). +#[inline] +fn ab(t: &Tx, sx: f32, sy: f32, k: f32) -> [f32; 4] { + if k <= 0.0 { + t.sac(sx, sy) + } else if k >= 1.0 { + t.sbc(sx, sy) + } else { + mix(t.sac(sx, sy), t.sbc(sx, sy), k) + } +} + +/// RGB-split sample: red from (+d), blue from (−d). +#[inline] +fn split(t: &Tx, x: f32, y: f32, d: f32, k: f32) -> [f32; 4] { + let c = ab(t, x, y, k); + if d.abs() < 0.25 { + return c; + } + let r = ab(t, x + d, y, k); + let b = ab(t, x - d, y, k); + [r[0], c[1], b[2], c[3].max(r[3]).max(b[3])] +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let p = t.p; + let (w, h) = (t.wf, t.hf); + let env = bell(p); + let k = mid_mix(p); + let s = t.seed(); + let u = t.unit().max(0.01); + Some(match id { + "chaos" => { + let amt = t.frac("amount"); + let frame = (p * 30.0).floor() as i32; + let amp = amt * env * w * 0.15; + paint(t.w, t.h, |x, y| { + // two block grids of different sizes, re-randomised a few times per second + let (bx, by) = ((x / (w / 8.0)).floor() as i32, (y / (h / 6.0)).floor() as i32); + let (cx, cy) = ((x / (w / 23.0)).floor() as i32, (y / (h / 13.0)).floor() as i32); + let r = hash(bx, by, s.wrapping_add(frame as u32)); + let fine = hash(cx, cy, s.wrapping_add(frame as u32 * 7 + 1)); + let (ox, oy) = if r > 0.55 { ((hash(bx, by, s + 99) - 0.5) * amp, (hash(bx, by, s + 7) - 0.5) * amp * 0.3) } else { (0.0, 0.0) }; + let swap = fine < k; + let kk = if swap { 1.0 } else { 0.0 }; + let kk = mix([kk; 4], [k; 4], 1.0 - env)[0]; + split(t, x - ox, y - oy, (fine - 0.5) * amt * env * 30.0 * u, kk) + }) + } + "earthquake" => { + let shake = t.px("shake"); + let (sh, n) = shutter(t, 0.03); + let rot = 0.04 * shake / w.max(1.0) * 10.0; + paint(t.w, t.h, |x, y| { + temporal(x, y, p, sh, n, &|x, y, q| { + let e = bell(q); + let ox = (vnoise(q * 45.0, 0.5, s) - 0.5) * 2.0 * shake * e; + let oy = (vnoise(q * 45.0, 7.5, s + 1) - 0.5) * 2.0 * shake * e; + let r = (vnoise(q * 30.0, 3.5, s + 2) - 0.5) * rot * e; + let zoom = 1.0 + 0.08 * e; + let (sn, cs) = r.sin_cos(); + let (ux, uy) = ((x - w / 2.0) / zoom, (y - h / 2.0) / zoom); + let (sx, sy) = (ux * cs - uy * sn + w / 2.0 - ox, ux * sn + uy * cs + h / 2.0 - oy); + ab(t, sx, sy, mid_mix(q)) + }) + }) + } + "flicker" => { + let nf = t.num("flickers").round().clamp(1.0, 40.0); + let j = (p * nf * 2.0).floor() as i32; + let show_b = hash(j, 3, s) < smoothstep(0.1, 0.9, p); + let gain = 1.0 + (hash(j, 5, s) - 0.5) * 1.2 * env; + paint(t.w, t.h, |x, y| { + let c = if show_b { t.pb(x, y) } else { t.pa(x, y) }; + [c[0] * gain, c[1] * gain, c[2] * gain, c[3]] + }) + } + "glass" => { + let cell = t.px("cell_size").max(4.0); + let refr = t.px("refraction"); + paint(t.w, t.h, |x, y| { + let (gx, gy) = ((x / cell).floor() as i32, (y / cell).floor() as i32); + let (mut best, mut second, mut id) = (f32::INFINITY, f32::INFINITY, (0, 0)); + for j in -1..=1 { + for i in -1..=1 { + let (cx, cy) = (gx + i, gy + j); + let px = (cx as f32 + hash(cx, cy, s)) * cell; + let py = (cy as f32 + hash(cx, cy, s + 1)) * cell; + let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt(); + if d < best { + second = best; + best = d; + id = (cx, cy); + } else if d < second { + second = d; + } + } + } + let tau = 0.3 + 0.4 * hash(id.0, id.1, s + 2); + let kk = smoothstep(tau - 0.1, tau + 0.1, p); + let ang = hash(id.0, id.1, s + 3) * TAU; + let off = refr * env * hash(id.0, id.1, s + 4); + let c = ab(t, x + ang.cos() * off, y + ang.sin() * off, kk); + let edge = (-(second - best) / (1.5 * u.max(0.5))).exp() * env; + let facet = 1.0 + (hash(id.0, id.1, s + 5) - 0.5) * 0.4 * env; + add_light([c[0] * facet, c[1] * facet, c[2] * facet, c[3]], [1.0; 4], edge * 0.8) + }) + } + "glitch" => { + let amt = t.frac("amount"); + let frame = (p * 24.0).floor() as u32; + paint(t.w, t.h, |x, y| { + let band_h = h / (6.0 + 18.0 * hash(frame as i32, 0, s)); + let band = (y / band_h.max(1.0)).floor() as i32; + let r = hash(band, frame as i32, s); + let ox = if r > 0.6 { (hash(band, frame as i32, s + 1) - 0.5) * amt * env * w * 0.25 } else { 0.0 }; + let tau = 0.4 + 0.2 * hash(band, 9, s); + let kk = if p > tau { 1.0 } else { 0.0 }; + let kk = kk * env + k * (1.0 - env); + let c = split(t, x - ox, y, amt * env * 18.0 * u * (r - 0.3).max(0.0) * 2.0, kk); + // occasional blocky corruption + let (bx, by) = ((x / (24.0 * u.max(0.2))).floor() as i32, (y / (12.0 * u.max(0.2))).floor() as i32); + if hash(bx, by, s + frame * 3) > 1.0 - 0.04 * amt * env { + let v = hash(bx, by, s + 77); + return mix(c, [v, 1.0 - v, v * 0.5, 1.0], 0.8); + } + c + }) + } + "grunge" => { + let scale = t.px("scale").max(4.0); + let st = WipeStyle { feather: 2.0 * t.scale, border: t.px("edge_width"), border_color: t.color("edge_color"), aa: 1.0 }; + field_wipe(t, st, move |x, y| { + let n = fbm(x / scale, y / scale, s) * 0.8 + vnoise(x / (scale * 0.15), y / (scale * 0.15), s + 9) * 0.2; + n * scale * 3.0 + }) + } + "kaleidoscope" => { + let n = t.num("segments").round().clamp(2.0, 32.0); + let zoom = 1.0 + t.frac("zoom") * env; + let seg = TAU / n; + paint(t.w, t.h, |x, y| { + let (rx, ry) = (x - w / 2.0, y - h / 2.0); + let r = (rx * rx + ry * ry).sqrt() / zoom; + let th = ry.atan2(rx) + p * PI * 0.5; + let a = (th.rem_euclid(seg) - seg / 2.0).abs(); + let (kx, ky) = (w / 2.0 + r * a.cos(), h / 2.0 + r * a.sin()); + let kal = ab(t, kx, ky, k); + let plain = ab(t, x, y, k); + mix(plain, kal, smoothstep(0.0, 0.6, env)) + }) + } + "liquid_distortion" => { + let amt = t.px("amount"); + let sc = t.px("scale").max(4.0); + paint(t.w, t.h, |x, y| { + let ox = (fbm(x / sc + p * 2.0, y / sc, s) - 0.5) * 2.0 * amt * env; + let oy = (fbm(x / sc, y / sc - p * 2.0, s + 5) - 0.5) * 2.0 * amt * env; + ab(t, x + ox, y + oy, k) + }) + } + "tv_power" => { + let glow = t.color("glow_color"); + let line = t.px("line").max(1.0); + let (img, q) = if p < 0.5 { (t.a, p * 2.0) } else { (t.b, (1.0 - p) * 2.0) }; + // q: 0 = full picture, 1 = collapsed to a dot + let sy = 1.0 - smoothstep(0.0, 0.65, q); + let sx = 1.0 - smoothstep(0.6, 1.0, q); + let hy = (h / 2.0 * sy).max(line / 2.0 * sx); + let hx = w / 2.0 * sx; + let white = smoothstep(0.2, 0.7, q); + paint(t.w, t.h, |x, y| { + let (rx, ry) = (x - w / 2.0, y - h / 2.0); + let mut c = [0.0, 0.0, 0.0, 1.0]; + if rx.abs() <= hx && ry.abs() <= hy && hx > 0.0 { + let lx = w / 2.0 + rx / sx.max(1e-3); + let ly = h / 2.0 + ry / sy.max(1e-3); + let src = img.sample_bilinear_clamped(lx, ly.clamp(0.0, h)); + c = over(c, mix(src, glow, white)); + } + let dy = (ry.abs() - hy).max(0.0); + let dx = (rx.abs() - hx).max(0.0); + let halo = (-(dx * dx + dy * dy).sqrt() / (line * 3.0)).exp() * white; + add_light(c, glow, halo * 0.8) + }) + } + "vhs_damage" => { + let amt = t.frac("amount"); + let frame = (p * 30.0).floor() as u32; + let band_y = h * (1.2 - 1.4 * p); + paint(t.w, t.h, |x, y| { + let row = (y / (2.0 * u.max(0.5))).floor() as i32; + let jitter = (hash(row, frame as i32, s) - 0.5) * amt * env * w * 0.02; + let in_band = (-((y - band_y) / (h * 0.06)).powi(2)).exp(); + let ox = jitter + in_band * amt * env * w * 0.08 * (vnoise(y * 0.05, p * 20.0, s) - 0.5) * 2.0; + let c = split(t, x - ox, y, amt * env * 10.0 * u, k); + let grain = (hash(x as i32, y as i32, s + frame) - 0.5) * 0.25 * amt * env; + let band_noise = in_band * env * amt * 0.5 * hash(x as i32 / 3, row, frame); + let l = super::luma(c); + let desat = mix(c, [l, l, l, c[3]], 0.4 * amt * env); + [desat[0] + grain + band_noise, desat[1] + grain + band_noise, desat[2] + grain + band_noise, desat[3]] + }) + } + _ => return None, + }) +} + +#[inline] +fn over(dst: [f32; 4], src: [f32; 4]) -> [f32; 4] { + super::over(dst, src) +} diff --git a/crates/render/src/transitions.rs b/crates/render/src/transitions/legacy.rs similarity index 76% rename from crates/render/src/transitions.rs rename to crates/render/src/transitions/legacy.rs index 914b561c..7af9bb24 100644 --- a/crates/render/src/transitions.rs +++ b/crates/render/src/transitions/legacy.rs @@ -1,4 +1,6 @@ -//! Video transitions: combine the outgoing (A) and incoming (B) layers at progress `p` ∈ [0,1]. +//! The original (pre-26) transition implementations, kept bit-for-bit for the Legacy and Obsolete +//! folders and for the dissolves whose output the golden images pin (Cross Dissolve, Dip to +//! Black/White, Film/Additive Dissolve, Wipe). use filmcraft_geom::Affine; use filmcraft_project::{EffectInstance, ParamValue}; @@ -46,12 +48,52 @@ fn step(edge: f32, x: f32) -> f32 { ((x - edge) / EDGE + 0.5).clamp(0.0, 1.0) } -pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { +/// Render `e` if it is one of the legacy implementations; `None` for every other id. +pub(super) fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Option { + let id = e.effect.as_str(); + let base = id.strip_suffix("_legacy").unwrap_or(id); + let known = matches!( + id, + "cross_dissolve" + | "cross_dissolve_legacy" + | "morph_cut" + | "film_dissolve" + | "film_dissolve_legacy" + | "additive_dissolve" + | "additive_dissolve_legacy" + | "dip_to_black" + | "dip_to_black_legacy" + | "dip_to_white" + | "dip_to_white_legacy" + | "wipe" + | "clock_wipe_legacy" + | "radial_wipe_legacy" + | "venetian_blinds" + | "checker_wipe" + | "gradient_wipe" + | "center_split" + | "split_legacy" + | "band_slide" + | "push_legacy" + | "whip_legacy" + | "slide_legacy" + | "cross_zoom_legacy" + | "flip_over" + | "cube_spin" + | "page_turn" + ); + if !known { + return None; + } + Some(legacy(base, e, a, b, p)) +} + +fn legacy(id: &str, e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { let (w, h) = (a.w as f64, a.h as f64); let aspect = (a.w as f32) / (a.h as f32).max(1.0); let pd = p as f64; - match e.effect.as_str() { - "cross_dissolve" | "non_additive_dissolve" | "morph_cut" => masked(a, b, |_, _| p), + match id { + "cross_dissolve" | "morph_cut" => masked(a, b, |_, _| p), "film_dissolve" => { // blend in a gamma-2.2-like space for a filmic, less-dippy dissolve let mut out = Image::new(a.w, a.h); @@ -69,7 +111,7 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { out } "dip_to_black" | "dip_to_white" => { - let col = if e.effect == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; + let col = if id == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; let mut out = Image::filled(a.w, a.h, col); let (src, k) = if p < 0.5 { (a, 1.0 - p * 2.0) } else { (b, (p - 0.5) * 2.0) }; let mut s = src.clone(); @@ -86,19 +128,11 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { _ => 1.0 - step(p, u), }) } - "barn_doors" => masked(a, b, |u, _| 1.0 - step(p * 0.5, (u - 0.5).abs())), "clock_wipe" | "radial_wipe" => masked(a, b, |u, v| { let ang = ((u - 0.5) * aspect).atan2(-(v - 0.5)) / std::f32::consts::TAU; let ang = ang.rem_euclid(1.0); 1.0 - step(p, ang) }), - "iris_round" => { - let r = p * 0.8; - masked(a, b, move |u, v| 1.0 - step(r, (((u - 0.5) * aspect).powi(2) + (v - 0.5).powi(2)).sqrt())) - } - "iris_box" => masked(a, b, |u, v| 1.0 - step(p * 0.5, (u - 0.5).abs().max((v - 0.5).abs()))), - "iris_diamond" => masked(a, b, |u, v| 1.0 - step(p, (u - 0.5).abs() + (v - 0.5).abs())), - "iris_cross" => masked(a, b, |u, v| 1.0 - step(p * 0.5, (u - 0.5).abs().min((v - 0.5).abs()) * 2.0 - p * 0.0).min(1.0)), "venetian_blinds" => masked(a, b, |_, v| 1.0 - step(p, (v * 10.0).fract())), "checker_wipe" => masked(a, b, |u, v| { let cxv = (u * 8.0).floor() as i32 + (v * 8.0).floor() as i32; @@ -112,7 +146,6 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { ((p * (1.0 + soft) - g) / soft).clamp(0.0, 1.0) }) } - "inset" => masked(a, b, |u, v| if u < p && v < p { 1.0 } else { 0.0 }), "center_split" => { let mut out = b.clone(); let off_x = w * 0.5 * pd; @@ -151,10 +184,10 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { 2 => (0.0, -h), _ => (w, 0.0), }; - let ease = if e.effect == "whip" { ease_in_out(pd) } else { pd }; + let ease = if id == "whip" { ease_in_out(pd) } else { pd }; let mut out = shifted(a, dx * ease, dy * ease); over(&mut out, &shifted(b, dx * (ease - 1.0), dy * (ease - 1.0))); - if e.effect == "whip" { + if id == "whip" { let s = (1.0 - (2.0 * pd - 1.0).abs()) as f32 * 40.0 * (a.w as f32 / 1920.0); if s > 0.5 { crate::effects::gaussian(&mut out, s, 0.0, true); @@ -188,9 +221,9 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { // Horizontal squeeze approximating a 3D rotation (A shrinks, then B grows). let (src, k) = if pd < 0.5 { (a, 1.0 - pd * 2.0) } else { (b, (pd - 0.5) * 2.0) }; let k = k.max(0.001); - let cxp = if e.effect == "cube_spin" { if pd < 0.5 { 0.0 } else { w } } else { w / 2.0 }; + let cxp = if id == "cube_spin" { if pd < 0.5 { 0.0 } else { w } } else { w / 2.0 }; let m = Affine::translate(cxp, 0.0).then_apply(&Affine::scale(k, 1.0)).then_apply(&Affine::translate(-cxp, 0.0)); - if e.effect == "cube_spin" { + if id == "cube_spin" { let ma = Affine::scale(1.0 - pd, 1.0); let mb = Affine::translate(w * (1.0 - pd), 0.0).then_apply(&Affine::scale(pd.max(0.001), 1.0)); let mut out = a.transformed(a.w, a.h, &ma); @@ -199,7 +232,7 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { } src.transformed(a.w, a.h, &m) } - "page_peel" | "page_turn" => { + "page_turn" => { // Diagonal fold line sweeping from the bottom-right corner, with a shaded curl band. let mut out = masked(a, b, |u, v| step(1.0 - p * 1.1, 1.0 - (u + v) * 0.5 + 0.0) * 0.0 + if (u + v) * 0.5 > 1.0 - p { 1.0 } else { 0.0 }); let w_ = a.w; @@ -225,37 +258,3 @@ pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { fn ease_in_out(t: f64) -> f64 { if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 } } - -/// Audio crossfade gains (out, in) for progress p. -pub fn audio_gains(kind: &str, p: f32) -> (f32, f32) { - match kind { - "constant_gain" => (1.0 - p, p), - "exponential_fade" => ((1.0 - p).powi(3), 1.0 - (1.0 - p).powi(3)), - _ => ((p * std::f32::consts::FRAC_PI_2).cos(), (p * std::f32::consts::FRAC_PI_2).sin()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn endpoints() { - let a = Image::filled(16, 8, [1.0, 0.0, 0.0, 1.0]); - let b = Image::filled(16, 8, [0.0, 0.0, 1.0, 1.0]); - for def in filmcraft_project::effect_defs().iter().filter(|d| d.kind == filmcraft_project::EffectKind::VideoTransition) { - let e = def.instance(); - let end = apply(&e, &a, &b, 1.0); - let c = end.get(8, 4); - assert!(c.iter().all(|v| v.is_finite()), "{}", def.id); - if !matches!( - def.id, - "center_split" | "band_slide" | "page_peel" | "page_turn" | "cube_spin" | "iris_cross" | "inset" | "checker_wipe" | "venetian_blinds" - ) { - assert!(c[2] > 0.9 && c[0] < 0.1, "{} at p=1 shows B: {c:?}", def.id); - } - let start = apply(&e, &a, &b, 0.0); - assert!(start.px.iter().all(|v| v.is_finite())); - } - } -} diff --git a/crates/render/src/transitions/lights.rs b/crates/render/src/transitions/lights.rs new file mode 100644 index 00000000..a9930a72 --- /dev/null +++ b/crates/render/src/transitions/lights.rs @@ -0,0 +1,266 @@ +//! Lights & Blurs: light effects that peak at the midpoint while the clips swap underneath, and +//! blur-through transitions. + +use std::f32::consts::PI; + +use super::dissolve::perceptual_luma; +use super::wipe::angle_dir; +use super::{Tx, add_light, bell, blurred, disc_blur, ease_in, ease_out, hash, line_blur, luma, mid_mix, mix, paint, smoothstep, spin_blur, taps, zoom_blur}; +use crate::image::Image; + +/// Soft round light blob. +#[inline] +fn blob(x: f32, y: f32, cx: f32, cy: f32, r: f32) -> f32 { + let d2 = ((x - cx).powi(2) + (y - cy).powi(2)) / (r * r).max(1e-6); + (-d2).exp() +} + +/// The midpoint mix of A and B (unwarped). +fn base(t: &Tx, x: f32, y: f32, k: f32) -> [f32; 4] { + if k <= 0.0 { + t.pa(x, y) + } else if k >= 1.0 { + t.pb(x, y) + } else { + mix(t.pa(x, y), t.pb(x, y), k) + } +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let p = t.p; + let (w, h) = (t.wf, t.hf); + let k = mid_mix(p); + let env = bell(p); + Some(match id { + "burn_chroma" => { + let amt = t.frac("amount"); + let hue = p * PI * 2.0; + let tint = [1.0 + 0.5 * hue.cos(), 1.0 + 0.5 * (hue + 2.1).cos(), 1.0 + 0.5 * (hue + 4.2).cos(), 1.0]; + paint(t.w, t.h, |x, y| { + let c = base(t, x, y, k); + let g = amt * env * 1.8; + let l = luma(c); + [c[0] * (1.0 + g * tint[0]) + l * g * 0.3, c[1] * (1.0 + g * tint[1]) + l * g * 0.2, c[2] * (1.0 + g * tint[2]) + l * g * 0.1, c[3]] + }) + } + "chroma_leak" | "light_leak" => { + let amt = t.frac("amount"); + let s = t.seed(); + let leak = id == "light_leak"; + let user = t.color("color"); + let cols: [[f32; 4]; 3] = if leak { + [user, [user[0], user[1] * 0.8, user[2] * 0.5, 1.0], [1.0, 0.9, 0.7, 1.0]] + } else { + [[1.0, 0.15, 0.6, 1.0], [0.1, 0.8, 1.0, 1.0], [1.0, 0.85, 0.1, 1.0]] + }; + let blobs: Vec<(f32, f32, f32, [f32; 4])> = (0..3) + .map(|i| { + let r0 = hash(i, 1, s); + let r1 = hash(i, 2, s); + let side = if leak { 0.0 } else { r0 }; + let cx = w * (side + (r1 - 0.5) * 0.4 + (p - 0.5) * (0.8 + r0)); + let cy = h * (0.2 + 0.6 * hash(i, 3, s) + (p - 0.5) * (r1 - 0.5)); + (cx, cy, w.max(h) * (0.25 + 0.25 * hash(i, 4, s)), cols[i as usize]) + }) + .collect(); + paint(t.w, t.h, |x, y| { + let mut c = base(t, x, y, k); + for &(cx, cy, r, col) in &blobs { + c = add_light(c, col, blob(x, y, cx, cy, r) * amt * env * 0.7); + } + c + }) + } + "cross_zoom" | "zoom_blur" => { + let (cx, cy) = t.center(); + let st = t.frac("strength"); + let zooms = id == "cross_zoom"; + let za = if zooms { 1.0 + 3.0 * st * ease_in((p / 0.5).min(1.0)) } else { 1.0 + 0.1 * st * ease_in(p) }; + let zb = if zooms { 1.0 + 3.0 * st * (1.0 - ease_out(((p - 0.5) / 0.5).clamp(0.0, 1.0))) } else { 1.0 + 0.1 * st * (1.0 - ease_out(p)) }; + let blur = st * env * 0.6; + let n = taps(blur * w.max(h) * 0.5); + paint(t.w, t.h, |x, y| { + let s = |img: &Image, z: f32| zoom_blur(img, (x - cx) / z + cx, (y - cy) / z + cy, cx, cy, blur, n); + let a = if k < 1.0 { s(t.a, za) } else { [0.0; 4] }; + let b = if k > 0.0 { s(t.b, zb) } else { [0.0; 4] }; + mix(a, b, k) + }) + } + "directional_blur_transition" => { + let (dx, dy) = angle_dir(t.num("angle")); + let len = t.px("blur") * env; + let n = taps(len * 2.0); + let shift = len * 0.5; + paint(t.w, t.h, |x, y| { + let a = if k < 1.0 { line_blur(t.a, x - dx * shift * p, y - dy * shift * p, dx * len, dy * len, n) } else { [0.0; 4] }; + let b = if k > 0.0 { line_blur(t.b, x + dx * shift * (1.0 - p), y + dy * shift * (1.0 - p), dx * len, dy * len, n) } else { [0.0; 4] }; + mix(a, b, k) + }) + } + "flare" => { + let col = t.color("color"); + let amt = t.frac("amount"); + let (dx, dy) = angle_dir(t.num("angle") + 90.0); + let diag = (w * w + h * h).sqrt(); + let (fx, fy) = (w / 2.0 + dx * diag * (p - 0.5) * 0.9, h / 2.0 + dy * diag * (p - 0.5) * 0.9); + let unit = h.max(1.0); + paint(t.w, t.h, |x, y| { + let c = base(t, x, y, k); + let (rx, ry) = (x - fx, y - fy); + // core, wide halo, anamorphic streak along the flare's axis, two ghosts mirrored through the centre + let along = rx * dx + ry * dy; + let across = -rx * dy + ry * dx; + let core = blob(x, y, fx, fy, unit * 0.06) * 3.0 + blob(x, y, fx, fy, unit * 0.35) * 0.6; + let streak = (-(across.abs() / (unit * 0.01))).exp() * (-(along.abs() / (diag * 0.35))).exp() * 1.5; + let (gx, gy) = (w - fx, h - fy); + let ghost = blob(x, y, gx, gy, unit * 0.08) * 0.4 + blob(x, y, (gx + fx) / 2.0, (gy + fy) / 2.0, unit * 0.05) * 0.3; + add_light(c, col, (core + streak + ghost) * amt * env) + }) + } + "flash" => { + let col = t.color("color"); + let inten = t.frac("intensity").clamp(0.0, 1.0); + let cut = smoothstep(0.45, 0.55, p); + // a short flash centred on the cut + let f = inten * (-((p - 0.5) / 0.12).powi(2)).exp() * smoothstep(0.0, 0.2, env); + paint(t.w, t.h, |x, y| mix(base(t, x, y, cut), col, f)) + } + "glow" => { + let r = t.px("radius").max(1.0); + let inten = t.frac("intensity"); + let b = t.a.clone().lerp(t.b, k); + let g = blurred(&b, r * 0.5); + let gain = inten * env * 2.0; + paint(t.w, t.h, |x, y| { + let c = b.get(x as usize, y as usize); + let gl = g.get(x as usize, y as usize); + [c[0] + gl[0] * gain, c[1] + gl[1] * gain, c[2] + gl[2] * gain, c[3].max(gl[3] * gain.min(1.0))] + }) + } + "lens_blur" => { + let r = t.px("radius"); + let ra = r * smoothstep(0.0, 0.55, p); + let rb = r * (1.0 - smoothstep(0.45, 1.0, p)); + let n = ((r * 0.8) as usize).clamp(8, 48); + paint(t.w, t.h, |x, y| { + let a = if k < 1.0 { disc_blur(t.a, x, y, ra, n) } else { [0.0; 4] }; + let b = if k > 0.0 { disc_blur(t.b, x, y, rb, n) } else { [0.0; 4] }; + mix(a, b, k) + }) + } + "light_sweep" => { + let (dx, dy) = angle_dir(t.num("angle")); + let bw = t.px("width").max(2.0); + let col = t.color("color"); + let amt = t.frac("amount"); + let ends = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(cx, cy): (f32, f32)| cx * dx + cy * dy); + let lo = ends.iter().copied().fold(f32::INFINITY, f32::min); + let hi = ends.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let e = lo - bw + p * (hi - lo + 2.0 * bw); + paint(t.w, t.h, |x, y| { + let d = x * dx + y * dy - e; + let c = mix(t.pa(x, y), t.pb(x, y), 1.0 - smoothstep(-bw * 0.3, bw * 0.3, d)); + add_light(c, col, (-(d / (bw * 0.35)).powi(2)).exp() * amt * 2.0) + }) + } + "phosphor" => { + let col = t.color("color"); + let decay = t.frac("decay").clamp(0.0, 1.0); + let kk = smoothstep(0.15, 0.85, p); + let persist = (1.0 - p).powf(0.5 + 3.0 * decay) * smoothstep(0.0, 0.15, p); + paint(t.w, t.h, |x, y| { + let a = t.pa(x, y); + let c = mix(a, t.pb(x, y), kk); + let l = perceptual_luma(a).powi(2); + add_light(c, col, l * persist * 1.5) + }) + } + "radial_blur" => { + let (cx, cy) = t.center(); + let max = t.num("angle").to_radians(); + let ang = max * env; + let n = taps(ang * w.max(h) * 0.5); + let (ra, rb) = (max * 0.25 * ease_in(p), -max * 0.25 * (1.0 - ease_out(p))); + paint(t.w, t.h, |x, y| { + let rot = |r: f32| { + let (s, c) = (-r).sin_cos(); + let (ux, uy) = (x - cx, y - cy); + (cx + ux * c - uy * s, cy + ux * s + uy * c) + }; + let a = if k < 1.0 { + let (sx, sy) = rot(ra); + spin_blur(t.a, sx, sy, cx, cy, ang, n) + } else { + [0.0; 4] + }; + let b = if k > 0.0 { + let (sx, sy) = rot(rb); + spin_blur(t.b, sx, sy, cx, cy, ang, n) + } else { + [0.0; 4] + }; + mix(a, b, k) + }) + } + "ray" => { + let (cx, cy) = t.center(); + let amt = t.frac("amount"); + let len = t.frac("length").clamp(0.0, 1.0); + let b0 = t.a.clone().lerp(t.b, k); + let bright = { + let src = &b0; + paint(t.w, t.h, |x, y| { + let c = src.get(x as usize, y as usize); + let l = (perceptual_luma(c) - 0.55).max(0.0) * 2.2; + [c[0] * l, c[1] * l, c[2] * l, c[3] * l] + }) + }; + let n = 24; + paint(t.w, t.h, |x, y| { + let c = b0.get(x as usize, y as usize); + let r = zoom_blur(&bright, x, y, cx, cy, len * 0.9, n); + let g = amt * env * 3.0; + [c[0] + r[0] * g, c[1] + r[1] * g, c[2] + r[2] * g, c[3].max(r[3] * g.min(1.0))] + }) + } + "solarize" => { + let amt = t.frac("amount").clamp(0.0, 1.0) * env; + paint(t.w, t.h, |x, y| { + let c = base(t, x, y, k); + let sol = |v: f32| { + let g = v.max(0.0).powf(1.0 / 2.2).min(1.0); + let s = if g > 0.5 { 1.0 - g } else { g } * 2.0; + s.min(1.0).powf(2.2) + }; + let a = c[3]; + let s = [sol(c[0]) * a, sol(c[1]) * a, sol(c[2]) * a, a]; + mix(c, s, amt) + }) + } + "stripe" => { + let n = t.num("stripes").round().clamp(1.0, 64.0); + let (dx, dy) = angle_dir(t.num("angle") + 90.0); + let (px_, py_) = (-dy, dx); + let col = t.color("color"); + let ends = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let rng = |f: &dyn Fn(f32, f32) -> f32| { + let v = ends.map(|(a, b)| f(a, b)); + (v.iter().copied().fold(f32::INFINITY, f32::min), v.iter().copied().fold(f32::NEG_INFINITY, f32::max)) + }; + let (alo, ahi) = rng(&|x, y| x * dx + y * dy); + let (clo, chi) = rng(&|x, y| x * px_ + y * py_); + let span = (ahi - alo).max(1.0); + let bar = span * 0.08; + paint(t.w, t.h, |x, y| { + let i = (((x * px_ + y * py_) - clo) / (chi - clo).max(1.0) * n).floor().clamp(0.0, n - 1.0); + let qi = ((p - 0.4 * i / n) / 0.6).clamp(0.0, 1.0); + let e = alo - bar + qi * (span + 2.0 * bar); + let d = x * dx + y * dy - e; + let c = mix(t.pa(x, y), t.pb(x, y), 1.0 - smoothstep(-1.0, 1.0, d)); + let light = if d >= 0.0 && d < bar { (1.0 - d / bar).powi(2) * smoothstep(0.0, 0.1, qi) * (1.0 - smoothstep(0.9, 1.0, qi)) } else { 0.0 }; + add_light(c, col, light * 1.5) + }) + } + _ => return None, + }) +} diff --git a/crates/render/src/transitions/mod.rs b/crates/render/src/transitions/mod.rs new file mode 100644 index 00000000..ceb515f2 --- /dev/null +++ b/crates/render/src/transitions/mod.rs @@ -0,0 +1,411 @@ +//! Video transitions: combine the outgoing (A) and incoming (B) layers at progress `p` ∈ [0,1]. +//! +//! Every transition is procedural maths written for FilmCraft (no third-party artwork, no masks +//! or textures from other products). The folders follow Premiere 26's Effects panel (see +//! `filmcraft_project::vtransition`). Shared building blocks: +//! +//! - [`Tx`]: the inputs plus typed parameter access (defaults come from the definition, so older +//! projects that lack a newer parameter still render). +//! - [`paint`]: evaluate a per-pixel function in parallel. +//! - [`wipe::field_wipe`]: shape wipes from a scalar field (iris, clock, star, linear…) with +//! feather, border width/colour and anti-aliasing. +//! - [`cards`]: textured 3D quads under a perspective camera (flips, spins, cubes, folds). +//! +//! Contract (tested for every transition): `p = 0` is exactly the outgoing frame, `p = 1` exactly +//! the incoming frame, the output is finite and deterministic, and it changes continuously near +//! both ends. There is no GPU transition path: the GPU plan pre-renders transition layers on the +//! CPU (see `plan.rs`), so this module is the only implementation. +//! +//! Immersive (VR) transitions are flat approximations on the equirectangular frame: sampling wraps +//! horizontally and the iris uses great-circle distance, but there is no sphere re-projection. +//! Morph Cut is a cross dissolve (no face tracking), and the Smart Tools / Text transitions are +//! procedural stand-ins (no content analysis, no typed text). + +use filmcraft_project::{EffectInstance, ParamValue}; +use filmcraft_time::Tick; +use rayon::prelude::*; + +use crate::image::Image; + +pub mod cards; +mod dissolve; +mod grunge; +mod legacy; +mod lights; +mod motion; +mod special; +pub mod wipe; + +#[cfg(test)] +mod tests; + +/// Render transition `e` at progress `p` (full-resolution parameters). +pub fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { + apply_scaled(e, a, b, p, 1.0) +} + +/// Render transition `e` at progress `p`; `scale` converts pixel parameters (border width, blur +/// length, centre) from sequence pixels to working-image pixels (½ for half-resolution playback). +pub fn apply_scaled(e: &EffectInstance, a: &Image, b: &Image, p: f32, scale: f32) -> Image { + let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }; + if a.w == 0 || a.h == 0 || (a.w, a.h) != (b.w, b.h) { + return if p < 0.5 { a.clone() } else { b.clone() }; + } + if p <= 0.0 { + return a.clone(); + } + if p >= 1.0 { + return b.clone(); + } + if let Some(img) = legacy::apply(e, a, b, p) { + return img; + } + let t = Tx { e, a, b, p, w: a.w, h: a.h, wf: a.w as f32, hf: a.h as f32, scale: if scale.is_finite() && scale > 0.0 { scale } else { 1.0 } }; + let id = e.effect.as_str(); + dissolve::apply(id, &t) + .or_else(|| wipe::apply(id, &t)) + .or_else(|| motion::apply(id, &t)) + .or_else(|| lights::apply(id, &t)) + .or_else(|| grunge::apply(id, &t)) + .or_else(|| special::apply(id, &t)) + .unwrap_or_else(|| paint(t.w, t.h, |x, y| mix(t.sa(x, y), t.sb(x, y), p))) +} + +/// Audio crossfade gains (out, in) for progress p. +pub fn audio_gains(kind: &str, p: f32) -> (f32, f32) { + match kind { + "constant_gain" => (1.0 - p, p), + "exponential_fade" => ((1.0 - p).powi(3), 1.0 - (1.0 - p).powi(3)), + _ => ((p * std::f32::consts::FRAC_PI_2).cos(), (p * std::f32::consts::FRAC_PI_2).sin()), + } +} + +// --------------------------------------------------------------------------------------------- +// Inputs and parameters + +/// One transition evaluation: the effect, both layers, progress and the pixel scale. +pub(crate) struct Tx<'a> { + pub e: &'a EffectInstance, + pub a: &'a Image, + pub b: &'a Image, + pub p: f32, + pub w: usize, + pub h: usize, + pub wf: f32, + pub hf: f32, + pub scale: f32, +} + +impl Tx<'_> { + fn value(&self, id: &str) -> Option { + self.e.param(id).map(|p| p.value.clone()).or_else(|| self.e.def().and_then(|d| d.param(id)).map(|p| p.default.clone())) + } + /// A float parameter (definition default if the instance lacks it). + pub fn num(&self, id: &str) -> f32 { + let v = self.e.f64_at(id, Tick::ZERO) as f32; + if v.is_finite() { v } else { 0.0 } + } + /// A percentage parameter as 0..1. + pub fn frac(&self, id: &str) -> f32 { + self.num(id) / 100.0 + } + /// A pixel-size parameter in working-image pixels. + pub fn px(&self, id: &str) -> f32 { + self.num(id).max(0.0) * self.scale + } + pub fn choice(&self, id: &str) -> u32 { + match self.value(id) { + Some(ParamValue::Choice(c)) => c, + _ => 0, + } + } + pub fn flag(&self, id: &str) -> bool { + matches!(self.value(id), Some(ParamValue::Bool(true))) + } + /// A colour parameter as opaque linear-light RGBA (colour params are stored display-encoded). + pub fn color(&self, id: &str) -> [f32; 4] { + let c = match self.value(id) { + Some(ParamValue::Color(c)) => c, + _ => [1.0; 4], + }; + let l = |v: f32| filmcraft_color::srgb_to_linear(v.clamp(0.0, 1.0)); + [l(c[0]), l(c[1]), l(c[2]), 1.0] + } + /// The `center` point in working pixels (NaN components = frame centre). + pub fn center(&self) -> (f32, f32) { + let v = match self.value("center") { + Some(ParamValue::Vec2(v)) => v, + _ => filmcraft_geom::Vec2::new(f64::NAN, f64::NAN), + }; + let x = if v.x.is_finite() { v.x as f32 * self.scale } else { self.wf / 2.0 }; + let y = if v.y.is_finite() { v.y as f32 * self.scale } else { self.hf / 2.0 }; + (x, y) + } + pub fn seed(&self) -> u32 { + self.num("seed").max(0.0) as u32 + } + /// Unit motion vector of the `direction` param: the incoming clip enters *from* that side, so + /// "From West" moves left → right (+x). + pub fn dir(&self) -> (f32, f32) { + match self.choice("direction") { + 0 => (0.0, 1.0), + 1 => (-1.0, 0.0), + 2 => (0.0, -1.0), + _ => (1.0, 0.0), + } + } + /// Frame extent along the direction of travel. + pub fn travel(&self) -> f32 { + let (dx, dy) = self.dir(); + (dx.abs() * self.wf + dy.abs() * self.hf).max(1.0) + } + /// Reference size for resolution-independent defaults (1 at 1080p height). + pub fn unit(&self) -> f32 { + self.hf / 1080.0 + } + #[inline] + pub fn sa(&self, x: f32, y: f32) -> [f32; 4] { + self.a.sample_bilinear(x, y) + } + #[inline] + pub fn sb(&self, x: f32, y: f32) -> [f32; 4] { + self.b.sample_bilinear(x, y) + } + #[inline] + pub fn sac(&self, x: f32, y: f32) -> [f32; 4] { + self.a.sample_bilinear_clamped(x, y) + } + #[inline] + pub fn sbc(&self, x: f32, y: f32) -> [f32; 4] { + self.b.sample_bilinear_clamped(x, y) + } + /// Pixel of A / B at integer coordinates (fast path for unwarped reads). + #[inline] + pub fn pa(&self, x: f32, y: f32) -> [f32; 4] { + self.a.get_clamped(x as isize, y as isize) + } + #[inline] + pub fn pb(&self, x: f32, y: f32) -> [f32; 4] { + self.b.get_clamped(x as isize, y as isize) + } +} + +// --------------------------------------------------------------------------------------------- +// Pixel helpers + +/// Evaluate `f(x, y)` at every pixel centre (x, y in pixels, +0.5 centres), in parallel. +pub(crate) fn paint(w: usize, h: usize, f: impl Fn(f32, f32) -> [f32; 4] + Sync) -> Image { + let mut out = Image::new(w, h); + out.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { + let fy = y as f32 + 0.5; + for x in 0..w { + let c = f(x as f32 + 0.5, fy); + row[x * 4..x * 4 + 4].copy_from_slice(&c); + } + }); + out +} + +#[inline] +pub(crate) fn mix(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] { + [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k] +} +/// Premultiplied `src` over `dst`. +#[inline] +pub(crate) fn over(dst: [f32; 4], src: [f32; 4]) -> [f32; 4] { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} +#[inline] +pub(crate) fn scale4(c: [f32; 4], k: f32) -> [f32; 4] { + [c[0] * k, c[1] * k, c[2] * k, c[3] * k] +} +/// Add light `rgb × k` (keeps alpha at least as opaque as the light). +#[inline] +pub(crate) fn add_light(c: [f32; 4], rgb: [f32; 4], k: f32) -> [f32; 4] { + [c[0] + rgb[0] * k, c[1] + rgb[1] * k, c[2] + rgb[2] * k, (c[3] + k.max(0.0)).min(1.0)] +} +#[inline] +pub(crate) fn luma(c: [f32; 4]) -> f32 { + 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] +} +#[inline] +pub(crate) fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if e1 <= e0 { + return if x < e0 { 0.0 } else { 1.0 }; + } + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} +/// 0 at both ends, 1 at the midpoint (smooth). +#[inline] +pub(crate) fn bell(p: f32) -> f32 { + let t = 4.0 * p * (1.0 - p); + t * t * (3.0 - 2.0 * t) +} +/// The A→B mix weight for "distort out, distort in" transitions: switches around the midpoint. +#[inline] +pub(crate) fn mid_mix(p: f32) -> f32 { + smoothstep(0.3, 0.7, p) +} +#[inline] +pub(crate) fn ease_in_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 } +} +#[inline] +pub(crate) fn ease_out(t: f32) -> f32 { + 1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3) +} +#[inline] +pub(crate) fn ease_in(t: f32) -> f32 { + t.clamp(0.0, 1.0).powi(3) +} + +/// Integer hash → [0, 1). +#[inline] +pub(crate) fn hash(x: i32, y: i32, s: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(0x8DA6_B343) ^ (y as u32).wrapping_mul(0xD816_3841) ^ s.wrapping_mul(0xCB1A_B31F) ^ 0x9E37_79B9; + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + h = h.wrapping_mul(0x297A_2D39); + h ^= h >> 15; + (h >> 8) as f32 / (1u32 << 24) as f32 +} +/// Smooth value noise in [0, 1) with unit cell size. +pub(crate) fn vnoise(x: f32, y: f32, s: u32) -> f32 { + let (xi, yi) = (x.floor(), y.floor()); + let (fx, fy) = (x - xi, y - yi); + let (ux, uy) = (fx * fx * (3.0 - 2.0 * fx), fy * fy * (3.0 - 2.0 * fy)); + let (i, j) = (xi as i32, yi as i32); + let a = hash(i, j, s); + let b = hash(i + 1, j, s); + let c = hash(i, j + 1, s); + let d = hash(i + 1, j + 1, s); + let top = a + (b - a) * ux; + let bot = c + (d - c) * ux; + top + (bot - top) * uy +} +/// Fractal (4-octave) value noise in ~[0, 1). +pub(crate) fn fbm(x: f32, y: f32, s: u32) -> f32 { + let (mut v, mut amp, mut f, mut norm) = (0.0, 0.5, 1.0, 0.0); + for o in 0..4 { + v += vnoise(x * f, y * f, s.wrapping_add(o * 101)) * amp; + norm += amp; + amp *= 0.5; + f *= 2.03; + } + v / norm +} + +/// Average of `n` samples of `img` along the segment centred on (x, y) with half-extent (dx, dy). +pub(crate) fn line_blur(img: &Image, x: f32, y: f32, dx: f32, dy: f32, n: usize) -> [f32; 4] { + if n <= 1 || (dx.abs() + dy.abs()) < 0.5 { + return img.sample_bilinear_clamped(x, y); + } + let mut acc = [0.0; 4]; + for i in 0..n { + let t = i as f32 / (n - 1) as f32 * 2.0 - 1.0; + let s = img.sample_bilinear_clamped(x + dx * t, y + dy * t); + for k in 0..4 { + acc[k] += s[k]; + } + } + scale4(acc, 1.0 / n as f32) +} +/// Zoom (radial streak) blur towards (cx, cy): samples between the pixel and `k` of the way to +/// the centre. +pub(crate) fn zoom_blur(img: &Image, x: f32, y: f32, cx: f32, cy: f32, k: f32, n: usize) -> [f32; 4] { + let (dx, dy) = ((x - cx) * k * 0.5, (y - cy) * k * 0.5); + line_blur(img, x - dx, y - dy, dx, dy, n) +} +/// Spin blur about (cx, cy) over `ang` radians (centred on the pixel). +pub(crate) fn spin_blur(img: &Image, x: f32, y: f32, cx: f32, cy: f32, ang: f32, n: usize) -> [f32; 4] { + if n <= 1 || ang.abs() < 1e-4 { + return img.sample_bilinear_clamped(x, y); + } + let (rx, ry) = (x - cx, y - cy); + let mut acc = [0.0; 4]; + for i in 0..n { + let a = (i as f32 / (n - 1) as f32 - 0.5) * ang; + let (s, c) = a.sin_cos(); + let p = img.sample_bilinear_clamped(cx + rx * c - ry * s, cy + rx * s + ry * c); + for k in 0..4 { + acc[k] += p[k]; + } + } + scale4(acc, 1.0 / n as f32) +} +/// Disc (bokeh-like) blur of radius `r` with `n` golden-angle samples. +pub(crate) fn disc_blur(img: &Image, x: f32, y: f32, r: f32, n: usize) -> [f32; 4] { + if r < 0.5 || n <= 1 { + return img.sample_bilinear_clamped(x, y); + } + let mut acc = [0.0; 4]; + for i in 0..n { + let rr = r * ((i as f32 + 0.5) / n as f32).sqrt(); + let a = i as f32 * 2.399_963; + let p = img.sample_bilinear_clamped(x + rr * a.cos(), y + rr * a.sin()); + for k in 0..4 { + acc[k] += p[k]; + } + } + scale4(acc, 1.0 / n as f32) +} +/// Sample count for a blur of extent `len` pixels. +#[inline] +pub(crate) fn taps(len: f32) -> usize { + ((len.abs() * 0.5).ceil() as usize).clamp(1, 40) +} +/// Gaussian-blurred copy (σ in pixels). +pub(crate) fn blurred(img: &Image, sigma: f32) -> Image { + let mut out = img.clone(); + if sigma > 0.3 { + crate::effects::gaussian(&mut out, sigma, sigma, true); + } + out +} +/// Horizontal-wrap bilinear sample (equirectangular frames). +pub(crate) fn sample_wrap(img: &Image, x: f32, y: f32) -> [f32; 4] { + let w = img.w as f32; + let xw = (x - 0.5).rem_euclid(w); + let x0 = xw.floor(); + let tx = xw - x0; + let x0 = x0 as usize % img.w; + let x1 = (x0 + 1) % img.w; + let fy = (y - 0.5).clamp(0.0, img.h as f32 - 1.0); + let y0 = fy.floor() as usize; + let y1 = (y0 + 1).min(img.h - 1); + let ty = fy - y0 as f32; + let (a, b, c, d) = (img.get(x0, y0), img.get(x1, y0), img.get(x0, y1), img.get(x1, y1)); + let mut o = [0.0; 4]; + for k in 0..4 { + let top = a[k] + (b[k] - a[k]) * tx; + let bot = c[k] + (d[k] - c[k]) * tx; + o[k] = top + (bot - top) * ty; + } + o +} + +/// Average a per-pixel scene over `n` time samples spread over `shutter` (in progress units) +/// around `p` (motion blur). `n = 1` or `shutter = 0` evaluates once at `p`. +pub(crate) fn temporal(x: f32, y: f32, p: f32, shutter: f32, n: usize, f: &(impl Fn(f32, f32, f32) -> [f32; 4] + Sync)) -> [f32; 4] { + if n <= 1 || shutter <= 1e-5 { + return f(x, y, p); + } + let mut acc = [0.0; 4]; + for i in 0..n { + let q = (p + (i as f32 / (n - 1) as f32 - 0.5) * shutter).clamp(0.0, 1.0); + let c = f(x, y, q); + for k in 0..4 { + acc[k] += c[k]; + } + } + scale4(acc, 1.0 / n as f32) +} +/// Motion-blur settings from the `motion_blur` param: (shutter in progress units, samples). +pub(crate) fn shutter(t: &Tx, max: f32) -> (f32, usize) { + let mb = t.frac("motion_blur").clamp(0.0, 1.0); + if mb <= 0.0 { (0.0, 1) } else { (max * mb, ((mb * 12.0).ceil() as usize).clamp(2, 12)) } +} diff --git a/crates/render/src/transitions/motion.rs b/crates/render/src/transitions/motion.rs new file mode 100644 index 00000000..3f5a43c1 --- /dev/null +++ b/crates/render/src/transitions/motion.rs @@ -0,0 +1,701 @@ +//! Moving-frame transitions: Animation, Slide, Transformers and the camera-style Smart Tools. + +use std::f32::consts::{FRAC_PI_2, PI}; + +use super::cards::{Card, V3, eye, focal_for}; +use super::{Tx, bell, ease_in, ease_in_out, ease_out, hash, line_blur, mid_mix, mix, over, paint, scale4, shutter, smoothstep, temporal, zoom_blur}; +use crate::image::Image; + +/// One textured card in a 3D scene. +#[derive(Clone, Copy)] +pub(crate) struct Layer<'a> { + pub card: Card, + pub front: &'a Image, + /// Image on the back face (mirrored so it reads correctly after a half turn about + /// the y axis (`mirror_y = false`) or the x axis (`mirror_y = true`)); `None` = one-sided. + pub back: Option<&'a Image>, + pub mirror_y: bool, + /// Visible part of the card in image pixels (x0, y0, x1, y1). + pub clip: [f32; 4], + pub shade: bool, + pub opacity: f32, +} + +impl<'a> Layer<'a> { + pub fn new(card: Card, front: &'a Image) -> Self { + Layer { card, front, back: None, mirror_y: false, clip: [0.0, 0.0, front.w as f32, front.h as f32], shade: false, opacity: 1.0 } + } + fn with_back(mut self, back: &'a Image, mirror_y: bool) -> Self { + self.back = Some(back); + self.mirror_y = mirror_y; + self + } + fn clipped(mut self, c: [f32; 4]) -> Self { + self.clip = c; + self + } + fn shaded(mut self) -> Self { + self.shade = true; + self + } +} + +/// Composite the layers seen through screen pixel (x, y), nearest last. +pub(crate) fn composite(layers: &[Layer], eye: V3, x: f32, y: f32) -> [f32; 4] { + const MAX: usize = 8; + let mut hits = [(0.0f32, [0.0f32; 4]); MAX]; + let mut nh = 0; + for l in layers { + let Some((lx, ly, depth, front)) = l.card.hit(eye, x, y) else { continue }; + let [x0, y0, x1, y1] = l.clip; + let edge = (lx - x0).min(x1 - lx).min(ly - y0).min(y1 - ly); + let cov = (edge + 0.5).clamp(0.0, 1.0); + if cov <= 0.0 { + continue; + } + let (img, sx, sy) = if front { + (l.front, lx, ly) + } else if let Some(b) = l.back { + if l.mirror_y { (b, lx, b.h as f32 - ly) } else { (b, b.w as f32 - lx, ly) } + } else { + continue; + }; + let mut c = img.sample_bilinear_clamped(sx, sy); + let mut k = cov * l.opacity; + if l.shade { + let n = cross(l.card.ex, l.card.ey); + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-9); + let facing = (n[2] / len).abs(); + let light = 0.45 + 0.55 * facing; + c = [c[0] * light, c[1] * light, c[2] * light, c[3]]; + } + k = k.clamp(0.0, 1.0); + if nh < MAX { + hits[nh] = (depth, scale4(c, k)); + nh += 1; + } + } + let hits = &mut hits[..nh]; + hits.sort_by(|a, b| b.0.total_cmp(&a.0)); + hits.iter().fold([0.0; 4], |acc, (_, c)| over(acc, *c)) +} + +fn cross(a: V3, b: V3) -> V3 { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// Render a card scene built for each motion-blur time sample. +fn scene<'a>(t: &Tx<'a>, focal: f32, blur: (f32, usize), build: impl Fn(f32) -> Vec> + Sync) -> Image { + let (sh, n) = blur; + let qs: Vec = if n <= 1 || sh <= 0.0 { vec![t.p] } else { (0..n).map(|i| (t.p + (i as f32 / (n - 1) as f32 - 0.5) * sh).clamp(0.0, 1.0)).collect() }; + let scenes: Vec> = qs.iter().map(|&q| build(q)).collect(); + let e = eye(t.wf, t.hf, focal); + let inv = 1.0 / scenes.len() as f32; + paint(t.w, t.h, |x, y| { + let mut acc = [0.0; 4]; + for s in &scenes { + let c = composite(s, e, x, y); + for k in 0..4 { + acc[k] += c[k]; + } + } + scale4(acc, inv) + }) +} + +fn center3(t: &Tx) -> V3 { + [t.wf / 2.0, t.hf / 2.0, 0.0] +} + +/// "Back-out" easing with overshoot `s` (0 = plain ease-out). +fn back_out(x: f32, s: f32) -> f32 { + let x = x.clamp(0.0, 1.0) - 1.0; + 1.0 + (s + 1.0) * x * x * x + s * x * x +} + +/// Damped spring from 0 to exactly 1. +fn spring(x: f32, bounce: f32) -> f32 { + let w = (2.0 + 4.0 * bounce) * PI; + let k = 4.0 + 6.0 * (1.0 - bounce); + let f = |u: f32| 1.0 - (-k * u).exp() * (w * u).cos(); + // a slower start (u = x^1.6) so the move reads before the bounce + let x = x.clamp(0.0, 1.0).powf(1.6); + f(x) + x * (1.0 - f(1.0)) +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let c = center3(t); + let (w, h) = (t.wf, t.hf); + let (dx, dy) = t.dir(); + let horiz = dx != 0.0; + let travel = t.travel(); + let far = focal_for(w, h, 0.0); + Some(match id { + // ---------------- Slide ---------------- + "push" => scene(t, far, shutter(t, 0.12), |q| { + let e = ease_in_out(q); + vec![ + Layer::new(Card::flat().translate([dx * travel * e, dy * travel * e, 0.0]), t.a), + Layer::new(Card::flat().translate([dx * travel * (e - 1.0), dy * travel * (e - 1.0), 0.0]), t.b), + ] + }), + "slide" => scene(t, far, shutter(t, 0.12), |q| { + let e = ease_in_out(q); + vec![Layer::new(Card::flat(), t.a), Layer::new(Card::flat().translate([dx * travel * (e - 1.0), dy * travel * (e - 1.0), 0.0]), t.b)] + }), + "split" => { + let vertical_cut = t.choice("orientation") == 0; + scene(t, far, shutter(t, 0.12), move |q| { + let e = ease_in_out(q); + let (half_l, half_r) = + if vertical_cut { ([0.0, 0.0, w / 2.0, h], [w / 2.0, 0.0, w, h]) } else { ([0.0, 0.0, w, h / 2.0], [0.0, h / 2.0, w, h]) }; + let (ox, oy) = if vertical_cut { (w / 2.0 * e, 0.0) } else { (0.0, h / 2.0 * e) }; + vec![ + Layer::new(Card::flat(), t.b), + Layer::new(Card::flat().translate([-ox, -oy, -0.01]), t.a).clipped(half_l), + Layer::new(Card::flat().translate([ox, oy, -0.01]), t.a).clipped(half_r), + ] + }) + } + "whip" => { + let e = { + let p = t.p; + // fast in the middle: a steep sigmoid normalised to 0..1 + let s = |u: f32| 1.0 / (1.0 + (-(u - 0.5) * 14.0).exp()); + (s(p) - s(0.0)) / (s(1.0) - s(0.0)) + }; + let pushed = scene(t, far, (0.0, 1), |_| { + vec![ + Layer::new(Card::flat().translate([dx * travel * e, dy * travel * e, 0.0]), t.a), + Layer::new(Card::flat().translate([dx * travel * (e - 1.0), dy * travel * (e - 1.0), 0.0]), t.b), + ] + }); + let len = t.frac("blur") * bell(t.p) * travel * 0.18; + if len < 0.75 { + pushed + } else { + let n = super::taps(len * 2.0); + paint(t.w, t.h, |x, y| line_blur(&pushed, x, y, dx * len, dy * len, n)) + } + } + "roll" => { + // A and B ride a big wheel whose hub sits below (or beside) the frame. + let r = if horiz { h * 2.0 } else { w * 2.0 }; + let hub: V3 = if horiz { [w / 2.0, h / 2.0 + r, 0.0] } else { [w / 2.0 - r, h / 2.0, 0.0] }; + let gap = (if horiz { w } else { h }) * 1.15 / r; + let sign = if horiz { dx.signum() } else { dy.signum() }; + scene(t, far, shutter(t, 0.1), move |q| { + let a = ease_in_out(q) * gap * sign; + vec![Layer::new(Card::flat().rot_z(hub, a), t.a), Layer::new(Card::flat().rot_z(hub, a - gap * sign), t.b)] + }) + } + "roll_3d" => { + // a cube: A on the front face, B on the face that turns to the front + let side = if horiz { w } else { h }; + let cc: V3 = [w / 2.0, h / 2.0, side / 2.0]; + let focal = focal_for(w, h, t.frac("perspective")); + let sign = if horiz { dx.signum() } else { -dy.signum() }; + scene(t, focal, (0.0, 1), move |q| { + let a = ease_in_out(q) * FRAC_PI_2 * sign; + let back = side * 0.45 * bell(q); + let rot = |k: Card, ang: f32| if horiz { k.rot_y(cc, ang) } else { k.rot_x(cc, ang) }; + vec![ + Layer::new(rot(Card::flat(), a).translate([0.0, 0.0, back]), t.a).shaded(), + Layer::new(rot(rot(Card::flat(), -FRAC_PI_2 * sign), a).translate([0.0, 0.0, back]), t.b).shaded(), + ] + }) + } + "film_roll" => { + let gap = t.px("gap"); + let gcol = t.color("gap_color"); + let step = travel + gap; + let (sh, n) = shutter(t, 0.1); + let inside = move |u: f32, v: f32| u >= 0.0 && v >= 0.0 && u < w && v < h; + paint(t.w, t.h, |x, y| { + temporal(x, y, t.p, sh, n, &|x, y, q| { + let off = step * ease_in_out(q); + let (ax, ay) = (x - dx * off, y - dy * off); + if inside(ax, ay) { + return t.sac(ax, ay); + } + let (bx, by) = (ax + dx * step, ay + dy * step); + if inside(bx, by) { + return t.sbc(bx, by); + } + // the gap between the frames, with sprocket holes along both edges + let across = if horiz { y } else { x }; + let across_len = if horiz { h } else { w }; + let along = if horiz { ax } else { ay }; + let edge_band = (across / across_len < 0.12) || (across / across_len > 0.88); + let pitch = across_len / 14.0; + let f = (across / pitch).fract(); + let g = ((along.rem_euclid(step) - travel) / gap.max(1.0)).clamp(0.0, 1.0); + let hole = !edge_band && (0.25..0.75).contains(&f) && (0.3..0.7).contains(&g); + if hole { [0.5, 0.5, 0.5, 1.0] } else { gcol } + }) + }) + } + "stretch" => { + let e = ease_in_out(t.p); + // B grows from the entry side while A squeezes towards the exit side + let (len, axis_x) = if horiz { (w, true) } else { (h, false) }; + let fwd = if horiz { dx > 0.0 } else { dy > 0.0 }; + let split = if fwd { len * e } else { len * (1.0 - e) }; + paint(t.w, t.h, |x, y| { + let s = if axis_x { x } else { y }; + let (in_b, local) = if fwd { + if s < split { (true, s / split.max(1e-3) * len) } else { (false, (s - split) / (len - split).max(1e-3) * len) } + } else if s >= split { + (true, (s - split) / (len - split).max(1e-3) * len) + } else { + (false, s / split.max(1e-3) * len) + }; + let (sx, sy) = if axis_x { (local, y) } else { (x, local) }; + if in_b { t.sbc(sx, sy) } else { t.sac(sx, sy) } + }) + } + // ---------------- Animation ---------------- + "block_motion" => { + let cols = t.num("blocks").round().clamp(2.0, 32.0); + let rows = (cols * h / w).round().clamp(1.0, 32.0); + let (lanes, cells) = if horiz { (rows, cols) } else { (cols, rows) }; + let (sh, n) = shutter(t, 0.08); + let n = n.min(4); + let seed = 17; + paint(t.w, t.h, |x, y| { + temporal(x, y, t.p, sh, n, &|x, y, q| { + let (along, across, len_al, len_ac) = if horiz { (x, y, w, h) } else { (y, x, h, w) }; + let sgn = if horiz { dx } else { dy }; + let lane = (across / len_ac * lanes).floor().clamp(0.0, lanes - 1.0); + let cell_len = len_al / cells; + let mut col = [0.0f32; 4]; + let mut got_a = false; + let mut got_b = [0.0f32; 4]; + let mut has_b = false; + for ci in 0..cells as i32 { + // leading blocks (towards the exit side) leave first + let order = if sgn > 0.0 { (cells as i32 - 1 - ci) as f32 } else { ci as f32 } / cells; + let delay = 0.6 * (0.6 * order + 0.4 * hash(ci, lane as i32, seed)); + let qi = ((q - delay) / (1.0 - 0.6)).clamp(0.0, 1.0); + let e = ease_in_out(qi); + let off_a = sgn * len_al * e; + let off_b = sgn * len_al * (e - 1.0); + let (lo, hi) = (ci as f32 * cell_len, (ci + 1) as f32 * cell_len); + let pa = along - off_a; + if !got_a && pa >= lo && pa < hi { + let (sx, sy) = if horiz { (pa, y) } else { (x, pa) }; + col = t.sac(sx, sy); + got_a = true; + } + let pb = along - off_b; + if !has_b && pb >= lo && pb < hi { + let (sx, sy) = if horiz { (pb, y) } else { (x, pb) }; + got_b = t.sbc(sx, sy); + has_b = true; + } + } + if got_a { over(got_b, col) } else { got_b } + }) + }) + } + "flip_motion" | "spin_3d" => { + let focal = focal_for(w, h, t.frac("perspective")); + let flip = id == "flip_motion"; + let sign = if horiz { dx.signum() } else { -dy.signum() }; + let blur = if flip { shutter(t, 0.06) } else { (0.0, 1) }; + scene(t, focal, blur, move |q| { + let (e, back) = if flip { + // ease in, then settle with a small overshoot + (if q < 0.5 { ease_in(q * 2.0) * 0.5 } else { 0.5 + back_out(q * 2.0 - 1.0, 1.2) * 0.5 }, 0.25 * bell(q)) + } else { + (ease_in_out(q), 0.8 * bell(q)) + }; + let a = e * PI * sign; + let card = if horiz { Card::flat().rot_y(c, a) } else { Card::flat().rot_x(c, a) }; + let card = card.translate([0.0, 0.0, back * w.max(h)]); + vec![Layer::new(card, t.a).with_back(t.b, !horiz).shaded()] + }) + } + "spinback_3d" => { + let depth = t.frac("depth"); + let focal = focal_for(w, h, 0.6); + let sign = if horiz { dx.signum() } else { -dy.signum() }; + scene(t, focal, (0.0, 1), move |q| { + let z = depth * w.max(h) * 1.5 * bell(q); + if q < 0.5 { + let a = ease_in(q * 2.0) * FRAC_PI_2 * sign; + let k = if horiz { Card::flat().rot_x(c, a) } else { Card::flat().rot_y(c, a) }; + vec![Layer::new(k.translate([0.0, 0.0, z]), t.a).shaded()] + } else { + let a = -(1.0 - ease_out((q - 0.5) * 2.0)) * FRAC_PI_2 * sign; + let k = if horiz { Card::flat().rot_x(c, a) } else { Card::flat().rot_y(c, a) }; + vec![Layer::new(k.translate([0.0, 0.0, z]), t.b).shaded()] + } + }) + } + "fold_motion" => { + // The entry half folds over onto the exit half (showing its back), then the folded + // stack swings towards the viewer about the exit edge, revealing B. + let focal = focal_for(w, h, t.frac("perspective")); + let sg = if dx > 0.0 || dy > 0.0 { 1.0 } else { -1.0 }; + let (entry, mid_axis, exit_axis): ([f32; 4], V3, V3) = match (horiz, sg > 0.0) { + (true, true) => ([0.0, 0.0, w / 2.0, h], [w / 2.0, 0.0, 0.0], [w, 0.0, 0.0]), + (true, false) => ([w / 2.0, 0.0, w, h], [w / 2.0, 0.0, 0.0], [0.0, 0.0, 0.0]), + (false, true) => ([0.0, 0.0, w, h / 2.0], [0.0, h / 2.0, 0.0], [0.0, h, 0.0]), + (false, false) => ([0.0, h / 2.0, w, h], [0.0, h / 2.0, 0.0], [0.0, 0.0, 0.0]), + }; + let exit = match (horiz, sg > 0.0) { + (true, true) => [w / 2.0, 0.0, w, h], + (true, false) => [0.0, 0.0, w / 2.0, h], + (false, true) => [0.0, h / 2.0, w, h], + (false, false) => [0.0, 0.0, w, h / 2.0], + }; + scene(t, focal, (0.0, 1), move |q| { + let phi = PI * ease_in_out((q * 2.0).min(1.0)) * sg; + let psi = FRAC_PI_2 * ease_in(((q - 0.5) * 2.0).clamp(0.0, 1.0)) * sg; + let rot = |k: Card, axis: V3, a: f32| if horiz { k.rot_y(axis, a) } else { k.rot_x(axis, -a) }; + let flap = rot(rot(Card::flat().translate([0.0, 0.0, -0.5]), mid_axis, phi), exit_axis, psi); + let base = rot(Card::flat(), exit_axis, psi); + vec![ + Layer::new(Card::flat().translate([0.0, 0.0, 1.0]), t.b), + Layer::new(base, t.a).clipped(exit).shaded(), + Layer::new(flap, t.a).with_back(t.a, !horiz).clipped(entry).shaded(), + ] + }) + } + "pop_motion" => { + let s = t.frac("overshoot") * 4.0; + scene(t, far, shutter(t, 0.05), move |q| { + let sa = 1.0 - 0.3 * ease_in((q / 0.6).min(1.0)); + let qb = ((q - 0.15) / 0.85).clamp(0.0, 1.0); + let sb = back_out(qb, s).max(0.0); + let mut la = Layer::new(Card::flat().scale_about(c, sa), t.a); + la.opacity = 1.0 - smoothstep(0.35, 0.8, q); + let mut lb = Layer::new(Card::flat().scale_about(c, sb.max(1e-3)).translate([0.0, 0.0, -0.01]), t.b); + lb.opacity = smoothstep(0.1, 0.3, q); + vec![la, lb] + }) + } + "pull_motion" => scene(t, far, shutter(t, 0.1), move |q| { + let e = ease_in_out(q); + let s = 1.0 - 0.25 * bell(q); + vec![ + Layer::new(Card::flat().scale_about(c, s).translate([dx * travel * e, dy * travel * e, 0.0]), t.a), + Layer::new(Card::flat().scale_about(c, s).translate([dx * travel * (e - 1.0), dy * travel * (e - 1.0), 0.0]), t.b), + ] + }), + "spin_motion" => { + let turn = t.num("rotation").to_radians() * if t.choice("spin") == 1 { -1.0 } else { 1.0 }; + scene(t, far, shutter(t, 0.06), move |q| { + if q < 0.5 { + let k = ease_in(q * 2.0); + vec![Layer::new(Card::flat().rot_z(c, turn * 0.5 * k).scale_about(c, (1.0 - k).max(1e-3)), t.a)] + } else { + let k = ease_out((q - 0.5) * 2.0); + vec![Layer::new(Card::flat().rot_z(c, -turn * 0.5 * (1.0 - k)).scale_about(c, k.max(1e-3)), t.b)] + } + }) + } + "spring_motion" => { + let bounce = t.frac("bounce"); + scene(t, far, shutter(t, 0.08), move |q| { + let e = spring(q, bounce); + vec![ + Layer::new(Card::flat().translate([dx * travel * e, dy * travel * e, 0.0]), t.a), + Layer::new(Card::flat().translate([dx * travel * (e - 1.0), dy * travel * (e - 1.0), 0.0]), t.b), + ] + }) + } + "travel_motion" => { + let zoom = t.frac("zoom"); + let step = travel * 1.08; + scene(t, far, shutter(t, 0.08), move |q| { + let s = 1.0 - zoom * bell(q); + let pan = step * ease_in_out(q); + vec![ + Layer::new(Card::flat().translate([dx * pan, dy * pan, 0.0]).scale_about(c, s), t.a), + Layer::new(Card::flat().translate([dx * (pan - step), dy * (pan - step), 0.0]).scale_about(c, s), t.b), + ] + }) + } + // ---------------- Transformers ---------------- + "frame" => { + let fw = t.px("frame_width"); + let fc = t.color("frame_color"); + let smin = t.frac("scale").clamp(0.05, 1.0); + let p = t.p; + // phase: shrink into a frame, travel across, grow out of the frame + let shrink = smoothstep(0.0, 0.3, p) * (1.0 - smoothstep(0.7, 1.0, p)); + let s = 1.0 - (1.0 - smin) * shrink; + let move_t = ease_in_out(((p - 0.25) / 0.5).clamp(0.0, 1.0)); + let border = fw * shrink; + // just far enough that the off-stage frame (and its border) is fully out of view + let step = travel * 0.5 * (1.0 + s) + border * 2.0 + 2.0; + let layers = [(t.a, move_t * step), (t.b, (move_t - 1.0) * step)]; + paint(t.w, t.h, |x, y| { + let mut out = [0.0; 4]; + for (img, off) in layers { + // inverse of: scale about centre, then translate along the direction + let (ux, uy) = (x - dx * off, y - dy * off); + let lx = (ux - w / 2.0) / s + w / 2.0; + let ly = (uy - h / 2.0) / s + h / 2.0; + let bpx = border / s; + let inside = lx >= 0.0 && lx < w && ly >= 0.0 && ly < h; + let in_frame = lx >= -bpx && lx < w + bpx && ly >= -bpx && ly < h + bpx; + let c = if inside { + img.sample_bilinear_clamped(lx, ly) + } else if in_frame && bpx > 0.0 { + fc + } else { + continue; + }; + out = over(out, c); + } + out + }) + } + "louver" => { + let n = t.num("slats").round().clamp(1.0, 64.0); + let horizontal_slats = t.choice("orientation") == 0; + let p = t.p; + paint(t.w, t.h, |x, y| { + let (pos, len) = if horizontal_slats { (y, h) } else { (x, w) }; + let sl = len / n; + let i = (pos / sl).floor().clamp(0.0, n - 1.0); + let mid = (i + 0.5) * sl; + let stagger = 0.4 * i / n.max(1.0); + let q = ((p - stagger) / 0.6).clamp(0.0, 1.0); + let th = ease_in_out(q) * PI; + let co = th.cos(); + if co.abs() < 1e-3 { + return [0.0, 0.0, 0.0, 1.0]; + } + let d = pos - mid; + let local = d / co; // A (front) for cos > 0, B (back, re-flipped) for cos < 0 + if local.abs() > sl / 2.0 { + return [0.0, 0.0, 0.0, 1.0]; + } + let src = mid + if co > 0.0 { local } else { -local }; + let (sx, sy) = if horizontal_slats { (x, src) } else { (src, y) }; + let c = if co > 0.0 { t.sac(sx, sy) } else { t.sbc(sx, sy) }; + let light = 0.5 + 0.5 * co.abs(); + [c[0] * light, c[1] * light, c[2] * light, c[3]] + }) + } + "mirror_transition" => { + let (lo, hi) = (0.0f32, travel); + let refl = travel * 0.3; + let e = lo - refl + ease_in_out(t.p) * (hi - lo + 2.0 * refl); + let neg = dx + dy < 0.0; + paint(t.w, t.h, |x, y| { + // along-axis coordinate measured from the entry side + let s = if horiz { + if neg { w - x } else { x } + } else if neg { + h - y + } else { + y + }; + if s < e { + return t.pb(x, y); + } + let a = t.pa(x, y); + let d = s - e; + if d < refl { + let m = e - d; // mirror of s about the moving line + let (sx, sy) = if horiz { (if neg { w - m } else { m }, y) } else { (x, if neg { h - m } else { m }) }; + let r = t.sbc(sx, sy); + return mix(a, r, 0.75 * (1.0 - d / refl)); + } + a + }) + } + "page_peel" => page_peel(t), + "slice" => { + let n = t.num("slices").round().clamp(1.0, 64.0); + let ang = t.num("angle").to_radians(); + let (ax, ay) = (ang.cos(), ang.sin()); // slice direction (they slide along this) + let (px_, py_) = (-ay, ax); // across + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); + for (cx, cy) in corners { + let v = cx * px_ + cy * py_; + lo = lo.min(v); + hi = hi.max(v); + } + let span = (hi - lo).max(1.0); + let dist = w.abs() * ax.abs() + h * ay.abs() + span * 0.2; + let (sh, ns) = shutter(t, 0.08); + paint(t.w, t.h, |x, y| { + temporal(x, y, t.p, sh, ns, &|x, y, q| { + let i = (((x * px_ + y * py_) - lo) / span * n).floor().clamp(0.0, n - 1.0); + let sign = if (i as i32) % 2 == 0 { 1.0 } else { -1.0 }; + let qi = ((q - 0.3 * i / n.max(1.0)) / 0.7).clamp(0.0, 1.0); + let off = sign * dist * ease_in(qi); + let (sx, sy) = (x - ax * off, y - ay * off); + let zb = 1.0 + 0.05 * (1.0 - q); + let b = t.sbc((x - w / 2.0) / zb + w / 2.0, (y - h / 2.0) / zb + h / 2.0); + if sx >= 0.0 && sx < w && sy >= 0.0 && sy < h { + // keep the slice's own band only (slices do not bleed into each other) + let j = (((sx * px_ + sy * py_) - lo) / span * n).floor().clamp(0.0, n - 1.0); + if j == i { + return over(b, t.sac(sx, sy)); + } + } + b + }) + }) + } + "wave" => { + let amp = t.px("amplitude"); + let lambda = t.px("wavelength").max(4.0); + let neg = dx + dy < 0.0; + let len = travel; + let band = len * 0.25; + let e = -band + t.p * (len + 2.0 * band); + let env = bell(t.p); + paint(t.w, t.h, |x, y| { + let s = if horiz { + if neg { w - x } else { x } + } else if neg { + h - y + } else { + y + }; + let across = if horiz { y } else { x }; + let near = (-((s - e) / band).powi(2)).exp(); + let disp = amp * env * near * (2.0 * PI * (across / lambda) - t.p * 6.0).sin(); + let (sx, sy) = if horiz { (x + disp, y + disp * 0.3) } else { (x + disp * 0.3, y + disp) }; + let k = 1.0 - smoothstep(-band * 0.3, band * 0.3, s - e); + mix(t.sac(sx, sy), t.sbc(sx, sy), k) + }) + } + // ---------------- Smart Tools (camera-style) ---------------- + "motion_camera" => { + let zoom = t.frac("zoom"); + let rot = t.num("rotation").to_radians(); + let mb = t.frac("motion_blur"); + let p = t.p; + let k = mid_mix(p); + // A pushes in and turns; B arrives from the same push, settling to rest + let (za, ra) = (1.0 + zoom * ease_in(p.min(0.7) / 0.7), rot * ease_in(p)); + let (zb, rb) = (1.0 + zoom * (1.0 - ease_out(((p - 0.3) / 0.7).clamp(0.0, 1.0))), -rot * (1.0 - ease_out(p))); + let blur = mb * bell(p) * 0.35; + let n = super::taps(blur * w * 0.3); + paint(t.w, t.h, |x, y| { + let sample = |img: &Image, z: f32, r: f32| { + let (s, co) = (-r).sin_cos(); + let (ux, uy) = ((x - w / 2.0) / z, (y - h / 2.0) / z); + let (lx, ly) = (ux * co - uy * s + w / 2.0, ux * s + uy * co + h / 2.0); + zoom_blur(img, lx, ly, w / 2.0, h / 2.0, blur, n) + }; + let a = if k < 1.0 { sample(t.a, za, ra) } else { [0.0; 4] }; + let b = if k > 0.0 { sample(t.b, zb, rb) } else { [0.0; 4] }; + mix(a, b, k) + }) + } + "motion_tween" => { + let s = t.frac("scale"); + let p = t.p; + let e = ease_in_out(p); + let (za, zb) = (1.0 + s * e, 1.0 + s * (e - 1.0)); + paint(t.w, t.h, |x, y| { + let a = t.sac((x - w / 2.0) / za + w / 2.0, (y - h / 2.0) / za + h / 2.0); + let b = t.sbc((x - w / 2.0) / zb + w / 2.0, (y - h / 2.0) / zb + h / 2.0); + mix(a, b, e) + }) + } + _ => return None, + }) +} + +/// Page Peel: the corner lifts and rolls over a cylinder of radius `r`; the peeled part lies +/// face-down on top of the page (its back shows a washed-out mirror of A) and B shows beneath. +fn page_peel(t: &Tx) -> Image { + let (w, h) = (t.wf, t.hf); + let r = t.px("radius").max(1.0); + let shadow = t.flag("shadow"); + let (ox, oy) = match t.choice("corner") { + 0 => (0.0, 0.0), + 1 => (w, 0.0), + 3 => (0.0, h), + _ => (w, h), + }; + let (mut nx, mut ny) = (w / 2.0 - ox, h / 2.0 - oy); + let nl = (nx * nx + ny * ny).sqrt().max(1e-3); + nx /= nl; + ny /= nl; + let smax = (w - 2.0 * ox).abs() * nx.abs() + (h - 2.0 * oy).abs() * ny.abs(); + let f = -1.0 + t.p * (smax + r * 1.2 + 2.0); + let inside = |px: f32, py: f32| px >= 0.0 && py >= 0.0 && px < w && py < h; + paint(t.w, t.h, |x, y| { + let s = (x - ox) * nx + (y - oy) * ny; + let page_at = |so: f32| (x - nx * (s - so), y - ny * (s - so)); + let back = |c: [f32; 4], light: f32| { + let m = mix(c, [c[3], c[3], c[3], c[3]], 0.6); + [m[0] * light, m[1] * light, m[2] * light, m[3]] + }; + // 1. the flipped flat part (height 2r) over the unpeeled page + if s >= f { + let so = 2.0 * f - s - PI * r; + let (px, py) = page_at(so); + if inside(px, py) { + return back(t.sac(px, py), 0.95); + } + } + let d = f - s; + if (0.0..r).contains(&d) { + // 2. upper half of the cylinder (back side) + let phi = PI - (d / r).asin(); + let so = f - r * phi; + let (px, py) = page_at(so); + if inside(px, py) { + return back(t.sac(px, py), 0.75 + 0.25 * phi.sin()); + } + // 3. lower half (front side, shaded) + let phi = (d / r).asin(); + let so = f - r * phi; + let (px, py) = page_at(so); + if inside(px, py) { + let c = t.sac(px, py); + let l = 1.0 - 0.45 * (phi / FRAC_PI_2); + return [c[0] * l, c[1] * l, c[2] * l, c[3]]; + } + } + if s >= f { + return t.pa(x, y); + } + // 4. revealed incoming clip, with a soft shadow along the curl + let b = t.pb(x, y); + if shadow { + let k = 1.0 - 0.45 * (-(d - r).max(0.0) / (r * 1.5)).exp() * smoothstep(0.0, 0.05, t.p) * (1.0 - smoothstep(0.9, 1.0, t.p)); + return [b[0] * k, b[1] * k, b[2] * k, b[3]]; + } + b + }) +} + +#[cfg(test)] +mod unit { + use super::*; + + #[test] + fn easings_hit_their_ends() { + for f in [spring(0.0, 0.4), back_out(0.0, 1.0)] { + assert!(f.abs() < 1e-5); + } + assert!((spring(1.0, 0.4) - 1.0).abs() < 1e-5); + assert!((back_out(1.0, 1.0) - 1.0).abs() < 1e-5); + assert!(back_out(0.8, 1.6) > 1.0, "overshoots"); + } + + #[test] + fn flat_card_maps_pixels_to_themselves() { + let e = eye(64.0, 36.0, 500.0); + let (lx, ly, _, front) = Card::flat().hit(e, 10.5, 20.5).unwrap(); + assert!((lx - 10.5).abs() < 1e-3 && (ly - 20.5).abs() < 1e-3 && front); + let flipped = Card::flat().rot_y([32.0, 18.0, 0.0], PI); + let (lx, _, _, front) = flipped.hit(e, 10.5, 20.5).unwrap(); + assert!(!front && (lx - 53.5).abs() < 1e-2, "{lx}"); + } +} diff --git a/crates/render/src/transitions/special.rs b/crates/render/src/transitions/special.rs new file mode 100644 index 00000000..f214f41a --- /dev/null +++ b/crates/render/src/transitions/special.rs @@ -0,0 +1,185 @@ +//! Immersive Video (flat equirectangular approximations), Smart Tools shape transitions and the +//! Text folder's cell-based reveals. + +use std::f32::consts::TAU; + +use super::wipe::{WipeStyle, field_wipe, shape_dist, wipe_px}; +use super::{Tx, add_light, bell, blurred, ease_in, ease_in_out, ease_out, hash, mid_mix, mix, over, paint, sample_wrap, scale4, smoothstep}; +use crate::image::Image; + +/// Horizontal distance on a wrapping (360°) frame. +#[inline] +fn wrap_dx(x: f32, cx: f32, w: f32) -> f32 { + (x - cx + w / 2.0).rem_euclid(w) - w / 2.0 +} + +fn rot(x: f32, y: f32, deg: f32) -> (f32, f32) { + let (s, c) = deg.to_radians().sin_cos(); + (x * c + y * s, -x * s + y * c) +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let p = t.p; + let (w, h) = (t.wf, t.hf); + let env = bell(p); + let k = mid_mix(p); + let s = t.seed(); + Some(match id { + // ---------------- Immersive Video ---------------- + "vr_chroma_leaks" | "vr_light_leaks" => { + let amt = t.frac("amount"); + let chroma = id == "vr_chroma_leaks"; + let cols: [[f32; 4]; 4] = if chroma { + [[1.0, 0.1, 0.55, 1.0], [0.1, 0.75, 1.0, 1.0], [1.0, 0.85, 0.1, 1.0], [0.5, 0.2, 1.0, 1.0]] + } else { + [[1.0, 0.6, 0.25, 1.0], [1.0, 0.45, 0.15, 1.0], [1.0, 0.85, 0.6, 1.0], [1.0, 0.7, 0.4, 1.0]] + }; + let blobs: Vec<(f32, f32, f32, [f32; 4])> = (0..4) + .map(|i| { + let cx = w * (hash(i, 1, s) + (p - 0.5) * (0.5 + hash(i, 2, s))); + let cy = h * (0.2 + 0.6 * hash(i, 3, s)); + (cx, cy, h * (0.3 + 0.3 * hash(i, 4, s)), cols[i as usize]) + }) + .collect(); + paint(t.w, t.h, |x, y| { + let mut c = mix(t.pa(x, y), t.pb(x, y), k); + for &(cx, cy, r, col) in &blobs { + let d2 = (wrap_dx(x, cx, w).powi(2) + (y - cy).powi(2)) / (r * r); + c = add_light(c, col, (-d2).exp() * amt * env * 0.7); + } + c + }) + } + "vr_gradient_wipe" => { + let soft = t.frac("softness").clamp(0.005, 1.0) * 0.5; + let inv = t.flag("invert"); + let thr = -soft + p * (1.0 + 2.0 * soft); + paint(t.w, t.h, |x, y| { + let lon = x / w * TAU; + let g0 = (y / h + 0.04 * (lon * 2.0).sin()).clamp(0.0, 1.0); + let g = if inv { 1.0 - g0 } else { g0 }; + mix(t.pa(x, y), t.pb(x, y), smoothstep(-soft, soft, thr - g)) + }) + } + "vr_light_rays" => { + let (cx, cy) = t.center(); + let amt = t.frac("amount"); + paint(t.w, t.h, |x, y| { + let c = mix(t.pa(x, y), t.pb(x, y), k); + let (rx, ry) = (wrap_dx(x, cx, w), y - cy); + let a = ry.atan2(rx); + let r = (rx * rx + ry * ry).sqrt() / h; + let rays = (0.5 + 0.5 * (a * 23.0 + p * 3.0).sin() * (a * 7.0 - p * 2.0).cos()).powi(6); + let fall = (-r * 1.5).exp(); + add_light(c, [1.0, 0.97, 0.9, 1.0], (rays * fall + (-r * 6.0).exp() * 0.8) * amt * env * 2.0) + }) + } + "vr_mobius_zoom" => { + let zoom = t.frac("zoom"); + let twist = t.num("twist").to_radians(); + let (za, zb) = (1.0 + zoom * 3.0 * ease_in((p / 0.5).min(1.0)), 1.0 + zoom * 3.0 * (1.0 - ease_out(((p - 0.5) / 0.5).clamp(0.0, 1.0)))); + let rmax = (w * w + h * h).sqrt() / 2.0; + paint(t.w, t.h, |x, y| { + let sample = |img: &Image, z: f32, tw: f32| { + let (rx, ry) = (x - w / 2.0, y - h / 2.0); + let r = (rx * rx + ry * ry).sqrt(); + let a = ry.atan2(rx) + tw * (1.0 - r / rmax).max(0.0); + let r2 = r / z; + sample_wrap(img, w / 2.0 + r2 * a.cos(), h / 2.0 + r2 * a.sin()) + }; + let a = if k < 1.0 { sample(t.a, za, twist * env) } else { [0.0; 4] }; + let b = if k > 0.0 { sample(t.b, zb, -twist * env) } else { [0.0; 4] }; + mix(a, b, k) + }) + } + "vr_random_blocks" => { + let bs = t.px("block_size").max(2.0); + let soft = t.frac("softness").clamp(0.0, 0.45).max(0.01); + paint(t.w, t.h, |x, y| { + let (bx, by) = ((x / bs).floor() as i32, (y / bs).floor() as i32); + // wrap the block grid horizontally so the seam column matches + let cols = (w / bs).ceil().max(1.0) as i32; + let tau = soft + (1.0 - 2.0 * soft) * hash(bx.rem_euclid(cols), by, s); + mix(t.pa(x, y), t.pb(x, y), smoothstep(tau - soft, tau + soft, p)) + }) + } + "vr_spherical_blur" => { + let sigma = t.px("blur") * 0.5 * env; + let m = t.a.clone().lerp(t.b, k); + blurred(&m, sigma) + } + // ---------------- Smart Tools ---------------- + "shape_dissolve" => { + let shape = t.choice("shape"); + let size = t.px("size").max(4.0); + let rot_deg = t.num("rotation"); + let order = t.choice("order"); + let st = WipeStyle { feather: t.px("softness"), border: 0.0, border_color: [0.0; 4], aa: 1.0 }; + let (cols, rows) = ((w / size).ceil().max(1.0), (h / size).ceil().max(1.0)); + let diag = ((cols / 2.0).powi(2) + (rows / 2.0).powi(2)).sqrt().max(1.0); + field_wipe(t, st, move |x, y| { + let (cx, cy) = ((x / size).floor(), (y / size).floor()); + let (lx, ly) = (x - (cx + 0.5) * size, y - (cy + 0.5) * size); + let (u, v) = rot(lx / (size * 0.5), ly / (size * 0.5), rot_deg); + let delay = match order { + 1 => cx / cols, + 2 => (((cx + 0.5) - cols / 2.0).powi(2) + ((cy + 0.5) - rows / 2.0).powi(2)).sqrt() / diag, + _ => hash(cx as i32, cy as i32, s), + }; + (shape_dist(shape, u, v) + delay * 1.5) * size * 0.5 + }) + } + "shape_flow" => { + let shape = t.choice("shape"); + let (dx, dy) = t.dir(); + let st = WipeStyle { feather: t.px("softness"), border: 0.0, border_color: [0.0; 4], aa: 1.0 }; + let c1 = (w / 2.0, h / 2.0); + let c0 = (c1.0 - dx * w * 0.75, c1.1 - dy * h * 0.75); + let cover = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| shape_dist(shape, x - c1.0, y - c1.1)).fold(0.0f32, f32::max); + let rmax = cover * 1.05 + st.feather + 4.0; + let e = ease_out(p); + let (cx, cy) = (c0.0 + (c1.0 - c0.0) * e, c0.1 + (c1.1 - c0.1) * e); + let r = rmax * ease_in_out(p); + paint(t.w, t.h, |x, y| wipe_px(&st, t.pa(x, y), t.pb(x, y), shape_dist(shape, x - cx, y - cy) - r)) + } + // ---------------- Text ---------------- + "text_animator" => { + let cols = t.num("columns").round().clamp(2.0, 128.0); + let rows = t.num("rows").round().clamp(1.0, 64.0); + let (cw, ch) = (w / cols, h / rows); + paint(t.w, t.h, |x, y| { + let (ci, ri) = ((x / cw).floor().min(cols - 1.0), (y / ch).floor().min(rows - 1.0)); + let delay = 0.6 * (0.7 * ci / cols + 0.3 * ri / rows); + let q = ((p - delay) / 0.4).clamp(0.0, 1.0); + let (top, bot) = (ri * ch, (ri + 1.0) * ch); + let ya = y + ch * ease_in(q); + let yb = y - ch * (1.0 - ease_out(q)); + let a = if ya < bot { t.sac(x, ya) } else { [0.0; 4] }; + let b = if yb >= top { t.sbc(x, yb) } else { [0.0; 4] }; + mix(a, b, smoothstep(0.0, 1.0, q)) + }) + } + "typewriter" => { + let cols = t.num("columns").round().clamp(2.0, 128.0); + let rows = t.num("rows").round().clamp(1.0, 64.0); + let cursor = t.flag("cursor"); + let n = cols * rows; + let cur = (p * n).floor(); + let (cw, ch) = (w / cols, h / rows); + paint(t.w, t.h, |x, y| { + let (ci, ri) = ((x / cw).floor().min(cols - 1.0), (y / ch).floor().min(rows - 1.0)); + let i = ri * cols + ci; + if i < cur { + t.pb(x, y) + } else if i == cur && cursor { + // a caret block in the lower part of the cell + let ly = (y - ri * ch) / ch; + if ly > 0.15 { over(t.pa(x, y), scale4([1.0, 1.0, 1.0, 1.0], 0.75)) } else { t.pa(x, y) } + } else { + t.pa(x, y) + } + }) + } + _ => return None, + }) +} diff --git a/crates/render/src/transitions/tests.rs b/crates/render/src/transitions/tests.rs new file mode 100644 index 00000000..b209a782 --- /dev/null +++ b/crates/render/src/transitions/tests.rs @@ -0,0 +1,364 @@ +//! Property and golden tests for every video transition. +//! +//! Golden fingerprints (`goldens.txt`): a 4×4 grid of mean 8-bit RGB values at p = 0.25 / 0.5 / +//! 0.75 on two procedural test frames. Re-bless after an intended change with +//! `FILMCRAFT_BLESS=1 cargo test -p filmcraft-render transitions::tests::golden_fingerprints` +//! and review the diff. + +use filmcraft_geom::Vec2; +use filmcraft_project::{EffectDef, EffectKind, ParamKind, ParamValue, effect_defs}; + +use super::*; + +const W: usize = 128; +const H: usize = 72; + +/// Outgoing test frame: warm horizontal ramp with a checker overlay. +fn frame_a() -> Image { + paint(W, H, |x, y| { + let u = x / W as f32; + let cell = W as f32 / 8.0; + let chk = if ((x / cell).floor() as i32 + (y / cell).floor() as i32) % 2 == 0 { 0.15 } else { 0.0 }; + [0.2 + 0.7 * u + chk, 0.1 + 0.3 * (y / H as f32), 0.05 + chk, 1.0] + }) +} +/// Incoming test frame: cool vertical ramp with a bright disc. +fn frame_b() -> Image { + paint(W, H, |x, y| { + let v = y / H as f32; + let d = ((x - W as f32 * 0.62).powi(2) + (y - H as f32 * 0.4).powi(2)).sqrt(); + let disc = if d < H as f32 * 0.22 { 0.6 } else { 0.0 }; + [0.05 + disc, 0.2 + 0.2 * v + disc, 0.4 + 0.5 * v, 1.0] + }) +} + +fn transitions() -> impl Iterator { + effect_defs().iter().filter(|d| d.kind == EffectKind::VideoTransition) +} + +fn mean_abs(a: &Image, b: &Image) -> f32 { + a.px.iter().zip(&b.px).map(|(x, y)| (x - y).abs()).sum::() / a.px.len() as f32 +} + +#[test] +fn endpoints_are_exact_outgoing_and_incoming() { + let (a, b) = (frame_a(), frame_b()); + for d in transitions() { + let e = d.instance(); + assert_eq!(apply(&e, &a, &b, 0.0), a, "{} p=0 is A", d.id); + assert_eq!(apply(&e, &a, &b, 1.0), b, "{} p=1 is B", d.id); + } +} + +#[test] +fn continuous_near_both_ends() { + let (a, b) = (frame_a(), frame_b()); + for d in transitions() { + let e = d.instance(); + let near_a = mean_abs(&apply(&e, &a, &b, 0.01), &a); + let near_b = mean_abs(&apply(&e, &a, &b, 0.99), &b); + assert!(near_a < 0.06, "{}: p=0.01 jumps away from A by {near_a}", d.id); + assert!(near_b < 0.06, "{}: p=0.99 jumps away from B by {near_b}", d.id); + } +} + +#[test] +fn midpoint_is_a_real_transition_and_sane() { + let (a, b) = (frame_a(), frame_b()); + for d in transitions() { + let e = d.instance(); + for p in [0.3, 0.5, 0.7] { + let m = apply(&e, &a, &b, p); + assert_eq!((m.w, m.h), (W, H)); + assert!(m.px.iter().all(|v| v.is_finite()), "{} p={p}: non-finite", d.id); + assert!(m.px.chunks(4).all(|c| (-1e-4..=1.0 + 1e-4).contains(&c[3])), "{} p={p}: alpha out of range", d.id); + assert!(m.px.iter().all(|v| *v > -0.3 && *v < 50.0), "{} p={p}: wild values", d.id); + } + let m = apply(&e, &a, &b, 0.5); + let (da, db) = (mean_abs(&m, &a), mean_abs(&m, &b)); + assert!(da > 0.005 && db > 0.005, "{}: midpoint equals one side (ΔA {da}, ΔB {db})", d.id); + } +} + +#[test] +fn deterministic() { + let (a, b) = (frame_a(), frame_b()); + for d in transitions() { + let e = d.instance(); + assert_eq!(apply(&e, &a, &b, 0.37), apply(&e, &a, &b, 0.37), "{}", d.id); + } +} + +#[test] +fn no_nan_on_degenerate_inputs() { + let clear = Image::new(W, H); + let (a, b) = (frame_a(), frame_b()); + let tiny_a = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]); + let tiny_b = Image::filled(1, 1, [0.0, 0.0, 1.0, 1.0]); + let odd_a = Image::filled(3, 2, [0.5, 0.5, 0.0, 1.0]); + let odd_b = Image::filled(3, 2, [0.0, 0.5, 0.5, 1.0]); + for d in transitions() { + let e = d.instance(); + for p in [f32::NAN, -1.0, 1e-6, 0.25, 0.5, 0.999_999, 2.0] { + for (x, y) in [(&a, &clear), (&clear, &b), (&tiny_a, &tiny_b), (&odd_a, &odd_b)] { + let m = apply(&e, x, y, p); + assert!(m.px.iter().all(|v| v.is_finite()), "{} p={p} {}x{}", d.id, x.w, x.h); + } + } + // a single-sided fade from nothing ends exactly on B + assert_eq!(apply(&e, &clear, &b, 1.0), b); + } +} + +/// A non-default value for a parameter (to check every parameter is wired). +fn changed(def: &filmcraft_project::ParamDef) -> Option { + Some(match (&def.kind, &def.default) { + (ParamKind::Float { min, max, .. }, ParamValue::Float(v)) => { + let hi = if *max > 1e5 { v * 2.0 + 10.0 } else { *max }; + let target = if (hi - v).abs() > (v - min).abs() { v + (hi - v) * 0.5 } else { v - (v - min) * 0.5 }; + // pixel sizes are authored for 1080p; keep them meaningful on the small test frame + ParamValue::Float(if def.id == "border_width" { + 6.0 + } else if matches!(&def.kind, ParamKind::Float { unit: "px", .. }) && *v > 20.0 { + v * 0.25 + } else { + target + }) + } + (ParamKind::Choice(opts), ParamValue::Choice(c)) => ParamValue::Choice((c + 1) % opts.len() as u32), + (_, ParamValue::Bool(v)) => ParamValue::Bool(!v), + (_, ParamValue::Color(c)) => ParamValue::Color([1.0 - c[0], 0.5, c[2] * 0.3, 1.0]), + (ParamKind::Point, _) => ParamValue::Vec2(Vec2::new(12.0, 9.0)), + (ParamKind::Angle, ParamValue::Float(v)) => ParamValue::Float(v + 70.0), + _ => return None, + }) +} + +#[test] +fn every_parameter_changes_the_picture() { + let (a, b) = (frame_a(), frame_b()); + let mut failures = Vec::new(); + for d in transitions() { + let mut base = d.instance(); + // make shape/border parameters observable on the small test frame + let mut set = |id: &str, v: ParamValue| { + if let Some(p) = base.params.get_mut(id) { + p.value = v; + } + }; + if d.id == "shape_dissolve" { + set("size", ParamValue::Float(16.0)); + set("shape", ParamValue::Choice(3)); + } + for pd in &d.params { + let Some(v) = changed(pd) else { continue }; + let mut base = base.clone(); + if pd.id == "border_color" + && let Some(bw) = base.params.get_mut("border_width") + { + bw.value = ParamValue::Float(6.0); + } + let base = base; + let mut e = base.clone(); + e.params.get_mut(pd.id).unwrap().value = v; + if pd.id == "border_width" { + // a border only shows if it differs from the content + if let Some(c) = e.params.get_mut("border_color") { + c.value = ParamValue::Color([0.0, 1.0, 0.0, 1.0]); + } + } + let differs = [0.2, 0.35, 0.5, 0.65, 0.8].iter().any(|&p| mean_abs(&apply(&base, &a, &b, p), &apply(&e, &a, &b, p)) > 1e-4); + if !differs { + failures.push(format!("{}.{}", d.id, pd.id)); + } + } + } + assert!(failures.is_empty(), "parameters with no visible effect: {failures:?}"); +} + +#[test] +fn directions_differ() { + let (a, b) = (frame_a(), frame_b()); + for d in transitions().filter(|d| d.param("direction").is_some()) { + let mut renders = Vec::new(); + for dir in 0..4 { + let mut e = d.instance(); + e.params.get_mut("direction").unwrap().value = ParamValue::Choice(dir); + renders.push(apply(&e, &a, &b, 0.4)); + } + assert!(mean_abs(&renders[1], &renders[3]) > 1e-3, "{}: East vs West identical", d.id); + } +} + +#[test] +fn wipe_border_and_centre() { + let (a, b) = (frame_a(), frame_b()); + let def = filmcraft_project::find_effect("iris_round").unwrap(); + let mut e = def.instance(); + e.params.get_mut("border_width").unwrap().value = ParamValue::Float(3.0); + e.params.get_mut("border_color").unwrap().value = ParamValue::Color([0.0, 1.0, 0.0, 1.0]); + let m = apply(&e, &a, &b, 0.4); + let green = m.px.chunks(4).filter(|c| c[1] > 0.9 && c[0] < 0.1 && c[2] < 0.1).count(); + assert!(green > 20, "border drawn ({green} px)"); + // centre at the top-left: the incoming clip shows there first + let mut e = def.instance(); + e.params.get_mut("center").unwrap().value = ParamValue::Vec2(Vec2::new(0.0, 0.0)); + let m = apply(&e, &a, &b, 0.2); + assert_eq!(m.get(1, 1), b.get(1, 1)); + assert_eq!(m.get(W - 2, H - 2), a.get(W - 2, H - 2)); + // half-resolution parameters scale with the image + let half = apply_scaled(&def.instance(), &a, &b, 0.5, 0.5); + assert!(half.px.iter().all(|v| v.is_finite())); +} + +#[test] +fn linear_wipe_reveals_from_the_trailing_side() { + let (a, b) = (frame_a(), frame_b()); + let e = filmcraft_project::find_effect("linear_wipe").unwrap().instance(); // 90° = left → right + let m = apply(&e, &a, &b, 0.5); + assert_eq!(m.get(2, 10), b.get(2, 10)); + assert_eq!(m.get(W - 3, 10), a.get(W - 3, 10)); +} + +#[test] +fn push_moves_frames_by_the_eased_distance() { + let (a, b) = (frame_a(), frame_b()); + let mut e = filmcraft_project::find_effect("push").unwrap().instance(); + e.params.get_mut("motion_blur").unwrap().value = ParamValue::Float(0.0); + let m = apply(&e, &a, &b, 0.5); // eased 0.5 → half a frame, from the west + for x in [4usize, 20, 28] { + let want = b.get(x + W / 2, 12); + let got = m.get(x, 12); + assert!(want.iter().zip(got).all(|(p, q)| (p - q).abs() < 1e-3), "x={x}: {got:?} vs {want:?}"); + } +} + +#[test] +fn modern_and_legacy_ids_are_distinct_renders() { + let (a, b) = (frame_a(), frame_b()); + for (modern, legacy) in [("push", "push_legacy"), ("clock_wipe", "clock_wipe_legacy"), ("cross_zoom", "cross_zoom_legacy"), ("whip", "whip_legacy")] { + let m = apply(&filmcraft_project::find_effect(modern).unwrap().instance(), &a, &b, 0.4); + let l = apply(&filmcraft_project::find_effect(legacy).unwrap().instance(), &a, &b, 0.4); + assert!(mean_abs(&m, &l) > 1e-4, "{modern} vs {legacy}"); + } + // the legacy dissolves keep their exact maths + let e = filmcraft_project::find_effect("cross_dissolve_legacy").unwrap().instance(); + let m = apply(&e, &a, &b, 0.25); + let want = a.clone().lerp(&b, 0.25); + assert!(mean_abs(&m, &want) < 1e-6); +} + +// --------------------------------------------------------------------------------------------- +// Golden fingerprints + +fn fingerprint(img: &Image) -> String { + let mut s = String::new(); + for by in 0..4 { + for bx in 0..4 { + let mut acc = [0.0f32; 3]; + let (x0, x1, y0, y1) = (bx * W / 4, (bx + 1) * W / 4, by * H / 4, (by + 1) * H / 4); + for y in y0..y1 { + for x in x0..x1 { + let c = img.get(x, y); + for k in 0..3 { + acc[k] += c[k]; + } + } + } + let n = ((x1 - x0) * (y1 - y0)) as f32; + for v in acc { + s.push_str(&format!("{:02x}", ((v / n).clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + } + s +} + +fn golden_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/transitions/goldens.txt") +} + +#[test] +fn golden_fingerprints() { + let (a, b) = (frame_a(), frame_b()); + let mut lines = Vec::new(); + for d in transitions() { + let e = d.instance(); + for p in [0.25f32, 0.5, 0.75] { + lines.push(format!("{} {p:.2} {}", d.id, fingerprint(&apply(&e, &a, &b, p)))); + } + } + let path = golden_path(); + if std::env::var_os("FILMCRAFT_BLESS").is_some() { + std::fs::write(&path, lines.join("\n") + "\n").unwrap(); + return; + } + let want = std::fs::read_to_string(&path).expect("goldens.txt (bless with FILMCRAFT_BLESS=1)"); + let want: std::collections::HashMap<(String, String), String> = want + .lines() + .filter_map(|l| { + let mut it = l.split_whitespace(); + Some(((it.next()?.to_string(), it.next()?.to_string()), it.next()?.to_string())) + }) + .collect(); + let mut bad = Vec::new(); + for l in &lines { + let mut it = l.split_whitespace(); + let (id, p, got) = (it.next().unwrap(), it.next().unwrap(), it.next().unwrap()); + let Some(exp) = want.get(&(id.to_string(), p.to_string())) else { + bad.push(format!("{id} {p}: no golden")); + continue; + }; + let bytes = |s: &str| (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap_or(0) as i32).collect::>(); + let (g, e) = (bytes(got), bytes(exp)); + let worst = g.iter().zip(&e).map(|(x, y)| (x - y).abs()).max().unwrap_or(255); + if g.len() != e.len() || worst > 4 { + bad.push(format!("{id} {p}: max Δ {worst}")); + } + } + assert!(bad.is_empty(), "transition goldens differ: {bad:?}"); +} + +/// Contact sheets for eyeballing: `FILMCRAFT_TRANSITION_SHEETS= cargo test -p filmcraft-render +/// contact_sheets -- --ignored` writes one PNG per folder (rows = transitions, columns = p). +#[test] +#[ignore] +fn contact_sheets() { + let Some(dir) = std::env::var_os("FILMCRAFT_TRANSITION_SHEETS") else { return }; + let (cw, chh) = (192usize, 108usize); + let a = paint(cw, chh, |x, y| { + let u = x / cw as f32; + let cell = cw as f32 / 8.0; + let chk = if ((x / cell).floor() as i32 + (y / cell).floor() as i32) % 2 == 0 { 0.15 } else { 0.0 }; + [0.2 + 0.7 * u + chk, 0.1 + 0.3 * (y / chh as f32), 0.05 + chk, 1.0] + }); + let b = paint(cw, chh, |x, y| { + let v = y / chh as f32; + let d = ((x - cw as f32 * 0.62).powi(2) + (y - chh as f32 * 0.4).powi(2)).sqrt(); + let disc = if d < chh as f32 * 0.22 { 0.6 } else { 0.0 }; + [0.05 + disc, 0.2 + 0.2 * v + disc, 0.4 + 0.5 * v, 1.0] + }); + let ps = [0.1f32, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9]; + let key = |d: &EffectDef| d.category.join("-").replace([' ', '&'], ""); + let mut folders: Vec = transitions().map(key).collect(); + folders.dedup(); + for f in folders { + let defs: Vec<_> = transitions().filter(|d| key(d) == f).collect(); + let (sw, sh) = (cw * ps.len(), chh * defs.len()); + let mut sheet = ::image::RgbaImage::new(sw as u32, sh as u32); + for (r, d) in defs.iter().enumerate() { + for (c, &p) in ps.iter().enumerate() { + let m = apply(&d.instance(), &a, &b, p); + let px = m.over_black_rgba8(); + for y in 0..chh { + for x in 0..cw { + let i = (y * cw + x) * 4; + sheet.put_pixel((c * cw + x) as u32, (r * chh + y) as u32, ::image::Rgba([px[i], px[i + 1], px[i + 2], 255])); + } + } + } + eprintln!("{f}: row {r} = {}", d.id); + } + sheet.save(std::path::Path::new(&dir).join(format!("{f}.png"))).unwrap(); + } +} diff --git a/crates/render/src/transitions/wipe.rs b/crates/render/src/transitions/wipe.rs new file mode 100644 index 00000000..537be103 --- /dev/null +++ b/crates/render/src/transitions/wipe.rs @@ -0,0 +1,272 @@ +//! Shape wipes driven by a scalar field: the incoming clip appears where `field(x, y) < T(p)`, +//! with `T` sweeping from below the field's minimum to above its maximum. Feather, border width, +//! border colour and anti-aliasing quality are shared by every wipe and iris. + +use std::f32::consts::{FRAC_PI_2, PI, TAU}; + +use super::{Tx, mix, paint, smoothstep}; +use crate::image::Image; + +/// Edge styling read from the common wipe params (missing params = 0 / off). +#[derive(Clone, Copy, Debug)] +pub struct WipeStyle { + pub feather: f32, + pub border: f32, + pub border_color: [f32; 4], + pub aa: f32, +} + +impl WipeStyle { + pub(crate) fn of(t: &Tx) -> Self { + let aa = [0.5, 1.0, 1.75, 2.5][t.choice("antialias").min(3) as usize] * t.scale.clamp(0.25, 1.0); + WipeStyle { feather: t.px("feather"), border: t.px("border_width"), border_color: t.color("border_color"), aa } + } + fn margin(&self) -> f32 { + self.border + self.feather + 2.0 * self.aa + 1.0 + } +} + +/// Coverage of (B, B+border) at signed distance `d` (negative = inside the B region). +#[inline] +pub(crate) fn coverage(st: &WipeStyle, d: f32) -> (f32, f32) { + let s = (st.feather + st.aa) * 0.5; + let kb = 1.0 - smoothstep(-s, s, d); + let ko = if st.border > 0.0 { 1.0 - smoothstep(st.border - s, st.border + s, d) } else { kb }; + (kb, ko.max(kb)) +} + +/// Composite one pixel of A/B/border for signed distance `d`. +#[inline] +pub(crate) fn wipe_px(st: &WipeStyle, a: [f32; 4], b: [f32; 4], d: f32) -> [f32; 4] { + let (kb, ko) = coverage(st, d); + let mut o = [0.0; 4]; + for k in 0..4 { + o[k] = a[k] * (1.0 - ko) + st.border_color[k] * (ko - kb) + b[k] * kb; + } + o +} + +/// Min/max of `field` over a coarse grid covering the frame (edges included). +fn field_range(w: f32, h: f32, field: &(impl Fn(f32, f32) -> f32 + Sync)) -> (f32, f32) { + let n = 48; + let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); + for j in 0..=n { + for i in 0..=n { + let v = field(w * i as f32 / n as f32, h * j as f32 / n as f32); + if v.is_finite() { + lo = lo.min(v); + hi = hi.max(v); + } + } + } + if lo.is_finite() { (lo, hi) } else { (0.0, 1.0) } +} + +/// The sweeping wipe: B where `field < T(p)` (field in pixels). +pub(crate) fn field_wipe(t: &Tx, st: WipeStyle, field: impl Fn(f32, f32) -> f32 + Sync) -> Image { + let (lo, hi) = field_range(t.wf, t.hf, &field); + let m = st.margin() + (hi - lo) * 0.01; + let thr = lo - m + t.p * (hi - lo + 2.0 * m); + paint(t.w, t.h, |x, y| wipe_px(&st, t.pa(x, y), t.pb(x, y), field(x, y) - thr)) +} + +/// Unit vector of travel for an angle param (0° = up, 90° = right). +pub(crate) fn angle_dir(deg: f32) -> (f32, f32) { + let r = deg.to_radians(); + (r.sin(), -r.cos()) +} + +/// Distance metric of a shape of unit "radius" (value < R ⇔ inside a shape of size R). +pub(crate) fn shape_dist(shape: u32, x: f32, y: f32) -> f32 { + let r = (x * x + y * y).sqrt(); + let th = (-y).atan2(x); // y up + let ngon = |n: f32, rot: f32| { + let seg = TAU / n; + let a = (th - rot).rem_euclid(seg) - seg / 2.0; + r * a.cos() / (PI / n).cos() + }; + match shape { + 1 => x.abs().max(y.abs()), + 2 => (x.abs() + y.abs()) * std::f32::consts::FRAC_1_SQRT_2, + 3 => star_dist(x, y, 5, 0.45), + 4 => { + // cardioid "heart": cusp at the top, point at the bottom + let s = 0.5 * (1.0 - th.sin()) + 0.08; + r / s * 0.5 + } + 5 => ngon(3.0, FRAC_PI_2 - PI / 3.0), + 6 => ngon(6.0, 0.0), + _ => r, + } +} + +/// Star metric with `n` points and inner radius `inner` (fraction of outer): straight edges +/// between each point and the neighbouring inner vertex. +pub(crate) fn star_dist(x: f32, y: f32, n: u32, inner: f32) -> f32 { + let r = (x * x + y * y).sqrt(); + if r < 1e-6 { + return 0.0; + } + let th = (-y).atan2(x) - FRAC_PI_2; // first point up + let seg = TAU / n.max(2) as f32; + let a = (th.rem_euclid(seg) - seg / 2.0).abs(); // 0 at an inner vertex, seg/2 at a point + let a = seg / 2.0 - a; // angle from the point + let ri = inner.clamp(0.05, 1.0); + // edge from P0 = (1, 0) to P1 = ri·(cos(seg/2), sin(seg/2)); ray at angle a + let (p1x, p1y) = (ri * (seg / 2.0).cos(), ri * (seg / 2.0).sin()); + let (ex, ey) = (p1x - 1.0, p1y); + let (dx, dy) = (a.cos(), a.sin()); + let denom = dx * ey - dy * ex; + let tr = if denom.abs() < 1e-9 { 1.0 } else { (ey) / denom }; // cross(P0, E) / cross(D, E) with P0 = (1, 0) + r / tr.max(0.05) +} + +fn rot(x: f32, y: f32, deg: f32) -> (f32, f32) { + let (s, c) = deg.to_radians().sin_cos(); + (x * c + y * s, -x * s + y * c) +} + +pub(super) fn apply(id: &str, t: &Tx) -> Option { + let st = WipeStyle::of(t); + let (cx, cy) = t.center(); + let (w, h) = (t.wf, t.hf); + let asp = w / h.max(1.0); + Some(match id { + "linear_wipe" | "soft_wipe" => { + let (dx, dy) = angle_dir(t.num("angle")); + field_wipe(t, st, move |x, y| x * dx + y * dy) + } + "clock_wipe" => { + let start = t.num("start").to_radians(); + let scale = (w + h) * 0.5; + field_wipe(t, st, move |x, y| { + let a = (x - cx).atan2(-(y - cy)) - start; + a.rem_euclid(TAU) / TAU * scale + }) + } + "radial_wipe" => { + let corner = t.choice("corner"); + let (ox, oy, a0) = match corner { + 1 => (w, 0.0, FRAC_PI_2), + 2 => (w, h, PI), + 3 => (0.0, h, -FRAC_PI_2), + _ => (0.0, 0.0, 0.0), + }; + let scale = (w + h) * 0.5; + field_wipe(t, st, move |x, y| { + // angle measured clockwise (screen) from the corner's first edge + let a = (y - oy).atan2(x - ox) - a0; + let a = a.rem_euclid(TAU); + let a = if a > PI * 1.5 { 0.0 } else { a.min(FRAC_PI_2) }; + a / FRAC_PI_2 * scale + }) + } + "star_wipe" => { + let n = t.num("points").round().clamp(3.0, 24.0) as u32; + let inner = t.frac("inner"); + let r0 = t.num("rotation"); + field_wipe(t, st, move |x, y| { + let (u, v) = rot(x - cx, y - cy, r0); + star_dist(u, v, n, inner) + }) + } + "panel_wipe" => { + let (dx, dy) = t.dir(); + let n = t.num("panels").round().clamp(1.0, 64.0); + let along_len = t.travel(); + let across_len = if dx != 0.0 { h } else { w }; + field_wipe(t, st, move |x, y| { + let along = x * dx + y * dy; + let across = if dx != 0.0 { y } else { x }; + let idx = (across / across_len * n).floor().clamp(0.0, n - 1.0); + along + idx * along_len * 0.6 / n + }) + } + "plateau_wipe" => { + let (dx, dy) = super::wipe::angle_dir(t.num("angle")); + let n = t.num("plateaus").round().clamp(1.0, 64.0); + let (lo, hi) = field_range(w, h, &|x: f32, y: f32| -x * dy + y * dx); + let span = (hi - lo).max(1.0); + let step = (w.max(h)) * 0.35 / n.max(1.0); + field_wipe(t, st, move |x, y| { + let along = x * dx + y * dy; + let across = ((-x * dy + y * dx) - lo) / span; + let idx = (across * n).floor().clamp(0.0, n - 1.0); + along + (idx - (n - 1.0) / 2.0).abs() * step + }) + } + "stretch_wipe" => { + let (dx, dy) = t.dir(); + let s_len = t.px("stretch").max(1.0); + let (lo, hi) = field_range(w, h, &|x: f32, y: f32| x * dx + y * dy); + let e = lo - s_len + t.p * (hi - lo + 2.0 * s_len); + paint(t.w, t.h, |x, y| { + let s = x * dx + y * dy; + if s < e { + t.pb(x, y) + } else if s < e + s_len { + let back = s - e; + let smear = t.sbc(x - dx * back, y - dy * back); + mix(smear, t.pa(x, y), smoothstep(0.0, 1.0, back / s_len)) + } else { + t.pa(x, y) + } + }) + } + "neon_wipe" => { + let (dx, dy) = angle_dir(t.num("angle")); + let glow = t.px("glow").max(1.0); + let col = t.color("color"); + let amt = t.frac("amount"); + let (lo, hi) = field_range(w, h, &|x: f32, y: f32| x * dx + y * dy); + let m = glow * 0.5 + 2.0; + let thr = lo - m + t.p * (hi - lo + 2.0 * m); + let fade = (t.p * 10.0).min((1.0 - t.p) * 10.0).min(1.0); + let st = WipeStyle { feather: 0.0, border: 0.0, border_color: [0.0; 4], aa: st.aa }; + paint(t.w, t.h, |x, y| { + let d = x * dx + y * dy - thr; + let base = wipe_px(&st, t.pa(x, y), t.pb(x, y), d); + let core = (-(d / (glow * 0.08)).powi(2)).exp(); + let halo = (-d.abs() / (glow * 0.5)).exp(); + let k = (core * 1.5 + halo * 0.8) * amt * fade; + super::add_light(base, col, k) + }) + } + // Legacy wipes rebuilt on the field engine (border / anti-aliasing / centre). + "iris_round" => field_wipe(t, st, move |x, y| ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()), + "iris_box" => field_wipe(t, st, move |x, y| (x - cx).abs().max((y - cy).abs() * asp)), + "iris_diamond" => field_wipe(t, st, move |x, y| (x - cx).abs() + (y - cy).abs() * asp), + "iris_cross" => field_wipe(t, st, move |x, y| { + let (u, v) = ((x - cx).abs(), (y - cy).abs() * asp); + let k = 0.3; + (u / k).max(v).min(u.max(v / k)) + }), + "barn_doors" => { + let vertical = t.choice("orientation") == 1; + field_wipe(t, st, move |x, y| if vertical { (x - cx).abs() } else { (y - cy).abs() * asp }) + } + "inset" => { + let (ox, oy) = match t.choice("corner") { + 1 => (w, 0.0), + 2 => (w, h), + 3 => (0.0, h), + _ => (0.0, 0.0), + }; + field_wipe(t, st, move |x, y| (x - ox).abs().max((y - oy).abs() * asp)) + } + "vr_iris_wipe" => { + // great-circle distance on the equirectangular sphere + let lon = |x: f32| x / w * TAU - PI; + let lat = |y: f32| FRAC_PI_2 - y / h * PI; + let (l0, p0) = (lon(cx), lat(cy)); + let (sp0, cp0) = p0.sin_cos(); + let scale = w / TAU; + field_wipe(t, st, move |x, y| { + let (l1, p1) = (lon(x), lat(y)); + let c = sp0 * p1.sin() + cp0 * p1.cos() * (l1 - l0).cos(); + c.clamp(-1.0, 1.0).acos() * scale + }) + } + _ => return None, + }) +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9e39f018..f4f299b5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -155,6 +155,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.ui.timeline.audio_track_h = v as f32; } } + if let Some(q) = s("effectsSearch") { + app.ui.effects_search = q.to_string(); + } if let Some(v) = p.get("safeMargins").and_then(Value::as_bool) { app.ui.program.safe_margins = v; } diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index e6a9a737..3388c822 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -21,11 +21,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let filter = q.to_ascii_lowercase(); let defs = filmcraft_project::effect_defs(); // folder tree: top-level categories in Premiere's order - let tops = ["Presets", "Lumetri Presets", "Audio Effects", "Audio Transitions", "Video Effects", "Video Transitions"]; + let tops = filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; let mut apply: Option = None; let mut preset_action: Option<(String, String)> = None; egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| { - for top in tops { + for &top in tops { if top == "Presets" { let any = filter.is_empty() || app.session.presets.all().iter().any(|p| p.name.to_ascii_lowercase().contains(&filter)); if any && folder_row(app, ui, top, 0, &filter) { @@ -75,7 +75,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // badges: accelerated / 32-bit / YUV let mut bx = r.max.x - 8.0; for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] { - if on && !is_tr || (is_tr && label == "⚡") { + if on { let br = Rect::from_min_size(pos2(bx - 22.0, r.min.y + 3.0), vec2(20.0, 14.0)); ui.painter().rect_filled(br, 2.0, Color32::from_rgb(48, 48, 48)); ui.painter().text(br.center(), Align2::CENTER_CENTER, label, Tokens::ui(8.5), t.text_dim); diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index 6dca439a..806ad1b0 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -186,6 +186,49 @@ fn apply_effect_appears_in_effect_controls() { assert!(!d.element_ids("effectControls.effect.").iter().any(|i| i == "effectControls.effect.gaussian_blur")); } +#[test] +fn effects_panel_lists_the_premiere_transition_folders() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + d.frames(3); + let folders = d.element_ids("effects.folder."); + for f in filmcraft_project::vtransition::VIDEO_TRANSITION_FOLDERS { + assert!(folders.contains(&format!("effects.folder.Video Transitions/{f}")), "{f}: {folders:?}"); + } + assert!(folders.iter().any(|f| f == "effects.folder.Legacy"), "{folders:?}"); + // search opens the matching folders: every modern wipe, and the legacy ones + d.ok("ui.set", json!({"effectsSearch": "wipe"})); + d.frames(3); + let items = d.element_ids("effects.item."); + for id in + ["clock_wipe", "linear_wipe", "neon_wipe", "panel_wipe", "plateau_wipe", "radial_wipe", "soft_wipe", "star_wipe", "stretch_wipe", "vr_gradient_wipe"] + { + assert!(items.iter().any(|i| i == &format!("effects.item.{id}")), "{id}: {items:?}"); + } + d.ok("ui.set", json!({"effectsSearch": "legacy"})); + d.frames(3); + let items = d.element_ids("effects.item."); + for id in ["additive_dissolve_legacy", "clock_wipe_legacy", "cross_dissolve_legacy", "push_legacy", "whip_legacy"] { + assert!(items.iter().any(|i| i == &format!("effects.item.{id}")), "{id}: {items:?}"); + } + // obsolete transitions stay out of the panel + d.ok("ui.set", json!({"effectsSearch": "venetian"})); + d.frames(3); + assert!(d.element_ids("effects.item.").is_empty()); + // clicking a sub-folder toggles it + d.ok("ui.set", json!({"effectsSearch": ""})); + d.frames(2); + d.ok("ui.click", json!({"id": "effects.folder.Video Transitions/Dissolve"})); + d.frames(2); + assert!(!d.element_ids("effects.item.").iter().any(|i| i == "effects.item.cross_dissolve")); + // applying from the panel's command path works for a legacy transition + let cut = track_clips(&d.sequence(), 0)[1]["start"].as_i64().unwrap(); + d.exec("playhead.set", json!({"time": cut})); + d.exec("sequence.applyVideoTransition", json!({"effect": "Iris Round"})); + let seq = d.sequence(); + assert!(seq["video"][0]["transitions"].as_array().unwrap().iter().any(|t| t["effect"] == "iris_round"), "{seq}"); +} + #[test] fn playback_toggle_and_stop() { let mut d = Driver::demo(); diff --git a/docs/contributing.md b/docs/contributing.md index ba370b5e..6dee3e97 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -130,11 +130,18 @@ stream is cut into blocks (there are tests for this). ### A transition -1. Define it with `vtrans(id, name, CATEGORY, params)` in `crates/project/src/effect.rs`. Audio - transitions use `EffectKind::AudioTransition`. -2. Implement it in `render::transitions::apply(e, a, b, p)`: outgoing image `a`, incoming image `b`, - progress `p` in 0..1. Audio crossfade curves go in `transitions::audio_gains`. -3. It can then be applied with `sequence.applyVideoTransition {"effect": ""}`. +1. Define video transitions with `tr(id, name, FOLDER, badges, params)` in + `crates/project/src/vtransition.rs` (folders follow Premiere 26: ten `Video Transitions/*` + folders plus `Legacy/Video Transitions`). Parameters are not animatable. Never rename an id: + projects store it. Audio transitions are `EffectKind::AudioTransition` defs in `effect.rs`. +2. Implement it in the matching module of `crates/render/src/transitions/` (`wipe`, `motion`, + `dissolve`, `lights`, `grunge`, `special`): read params through `Tx`, build pixels with `paint`, + shape wipes with `wipe::field_wipe` (feather/border/anti-aliasing for free) and 3D moves with + `cards`. Audio crossfade curves go in `transitions::audio_gains`. The property tests run over + every transition automatically; re-bless `goldens.txt` (see testing.md). +3. It can then be applied with `sequence.applyVideoTransition {"effect": "", "params": + {...}, "reverse": bool}` and edited with `sequence.setTransition`; `effects.list {"folder": + "Video Transitions/Wipe", "detail": true}` lists it with its parameters. ### A codec or container crate diff --git a/docs/testing.md b/docs/testing.md index 3a6c49bf..3dc69961 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -103,6 +103,15 @@ Each codec README has the full fixture matrix and the measured results. sRGB levels; `Tolerance::RENDER`). On failure the actual frame and a ×8 difference image go to `/target/golden-failures/`. `scenes_are_distinct` guards against blank or duplicate scenes. +- **Video transitions** (`crates/render/src/transitions/tests.rs`) are checked for every transition + (84 modern, 21 Legacy, 7 hidden Obsolete): p = 0 is exactly the outgoing frame and p = 1 exactly + the incoming one, near both ends the output is close to that frame (continuity), the midpoint differs + from both, the output is finite and deterministic (also on 1×1 / odd sizes and NaN progress), every + parameter visibly changes the picture, and every direction differs. Golden *fingerprints* (4×4 mean + RGB grid at p = 0.25/0.5/0.75, ±4 levels) live in `src/transitions/goldens.txt`; re-bless with + `FILMCRAFT_BLESS=1 cargo test -p filmcraft-render golden_fingerprints` and review the diff. + `FILMCRAFT_TRANSITION_SHEETS= cargo test -p filmcraft-render contact_sheets -- --ignored` + writes one contact-sheet PNG per folder for eyeballing (never commit them). - **Blessing:** `FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden` rewrites the references (and writes a `.attribution` sidecar for any new one; it prints the `ATTRIBUTION.md` row to add). References are original work rendered by FilmCraft, must stay under 50 KB (enforced on bless),