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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 11:14:59 -04:00
parent 7db132d98c
commit bff5b723ee
25 changed files with 3793 additions and 130 deletions

View File

@ -1048,7 +1048,7 @@ fn build() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<CommandSpec> {
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::<Vec<_>>()})).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::<Vec<_>>(), "redo": s.history.redo.iter().map(|h| &h.0).collect::<Vec<_>>()})
)),
@ -2356,11 +2368,133 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
.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::<Vec<_>>(),
"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<Value> {
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<Value> {
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<Value
} else {
cut - dur
};
let tr =
Transition { id: TransitionId(0), effect: def.instance(), start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse: false };
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
let label = format!("Apply {}", def.name);
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
Ok(json!({"transition": id.0}))
@ -2449,7 +2582,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})
};
json!({

View File

@ -971,7 +971,10 @@ pub fn audio_gain(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Value> {
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 {

View File

@ -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();

View File

@ -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<ParamDef>) -> 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<ParamDef>) -> 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<ParamDef>) -> 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<EffectDef> {
let mut v = vec![
// ---- intrinsic ----
@ -787,37 +783,6 @@ fn build_effects() -> Vec<EffectDef> {
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<EffectDef> {
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 {

View File

@ -16,6 +16,7 @@ pub mod mask;
pub mod mixer;
pub mod multicam;
pub mod transcript;
pub mod vtransition;
use std::collections::BTreeMap;

View File

@ -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<ParamDef>) -> 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<ParamDef>, extra: impl IntoIterator<Item = ParamDef>) -> Vec<ParamDef> {
v.extend(extra);
v
}
pub(crate) fn video_transition_defs() -> Vec<EffectDef> {
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<usize> = 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::<usize>(), 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());
}
}

View File

@ -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;
}

View File

@ -39,7 +39,7 @@ fn cpu_frame(img: crate::Image) -> Arc<VideoFrame> {
}
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() {

View File

@ -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]

View File

@ -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)
}

View File

@ -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<Image> {
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,
})
}

View File

@ -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

View File

@ -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<Image> {
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)
}

View File

@ -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<Image> {
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()));
}
}
}

View File

@ -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<Image> {
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,
})
}

View File

@ -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<ParamValue> {
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)) }
}

View File

@ -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<Layer<'a>> + Sync) -> Image {
let (sh, n) = blur;
let qs: Vec<f32> = 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<Vec<Layer>> = 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<Image> {
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}");
}
}

View File

@ -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<Image> {
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,
})
}

View File

@ -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<Item = &'static EffectDef> {
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::<f32>() / 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<ParamValue> {
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::<Vec<_>>();
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=<dir> 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<String> = 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();
}
}

View File

@ -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<Image> {
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,
})
}

View File

@ -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;
}

View File

@ -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<String> = 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);

View File

@ -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();

View File

@ -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": "<id>"}`.
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": "<id or name>", "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

View File

@ -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
`<workspace>/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=<dir> 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),