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:
parent
7db132d98c
commit
bff5b723ee
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod mask;
|
|||
pub mod mixer;
|
||||
pub mod multicam;
|
||||
pub mod transcript;
|
||||
pub mod vtransition;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)) }
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue