Merge branch 'worktree-agent-afeed4478aa7a6497' into integrate

# Conflicts:
#	crates/project/src/effect.rs
This commit is contained in:
Brandon Thomas
2026-10-02 11:15:38 -04:00
40 changed files with 6748 additions and 85 deletions
+6
View File
@@ -67,6 +67,12 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R
| `crates/golden/goldens/mask_color.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_color`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/mask_inverted.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_inverted`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/mask_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_opacity`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_distort.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_distort`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_ultra_key.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_ultra_key`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_glows.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_glows`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_spacer_stroke_shadow.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_spacer_stroke_shadow`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_vr_rotate.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_vr_rotate`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/vfx_stylize.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `vfx_stylize`) from procedurally generated media | MIT OR Apache-2.0 |
## Third-party media shown in screenshots
+1 -1
View File
@@ -138,7 +138,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
<p align="center"><sub>NASA's <i>Earth Views from the ISS</i> with a look applied, and the Effects browser open on the dissolves.</sub></p>
- **Around 55 video effects and 30 transitions.** Blurs, keys, distortions, stylize and colour effects. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter, and each animated parameter opens into **value and velocity graphs** with draggable influence handles.
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
+1 -1
View File
@@ -2058,7 +2058,7 @@ fn build() -> Vec<CommandSpec> {
}),
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()))
Ok(Value::Array(filmcraft_project::effect_defs().iter().map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "category": d.category, "folder": d.category.get(1), "badges": {"accelerated": d.accelerated, "float32": d.float32, "yuv": d.yuv}, "params": d.params.iter().map(|p| p.id).collect::<Vec<_>>()})).collect()))
}),
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<_>>()})
+2
View File
@@ -770,3 +770,5 @@ mod tests;
mod transcript_tests;
#[cfg(test)]
mod trim_tests;
#[cfg(test)]
mod vfx_tests;
+85
View File
@@ -0,0 +1,85 @@
//! M5.11: every video effect is listed with its folder and badges, applies by id or name through
//! `effects.apply` (undoable), takes parameters through `effects.setParam`, and renders.
use filmcraft_project::{EffectKind, effect_defs};
use serde_json::{Value, json};
use crate::Session;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn first_v1_clip(s: &Session) -> u64 {
s.active_sequence().unwrap().video_tracks[0].items[0].id.0
}
fn video_effects() -> Vec<&'static filmcraft_project::EffectDef> {
effect_defs().iter().filter(|d| d.kind == EffectKind::Video && !d.intrinsic && !filmcraft_project::graphic::is_layer_id(d.id)).collect()
}
#[test]
fn effects_list_reports_folders_and_badges() {
let mut s = demo();
let list = s.execute("effects.list", json!({})).unwrap();
let arr = list.as_array().unwrap();
let find = |id: &str| arr.iter().find(|e| e["id"] == id).cloned().unwrap_or_else(|| panic!("{id} not listed"));
let wsz = find("warp_stabilizer");
assert_eq!(wsz["folder"], "Distort");
assert_eq!(wsz["category"], json!(["Video Effects", "Distort"]));
assert_eq!(find("vr_rotate_sphere")["folder"], "Immersive Video");
assert_eq!(find("crop")["category"], json!(["Legacy", "Video Effects"]));
assert_eq!(find("luma_key")["badges"], json!({"accelerated": true, "float32": true, "yuv": true}));
assert_eq!(find("lens_flare")["folder"], "Lights & Glows");
let n =
arr.iter().filter(|e| e["kind"] == "Video" && e["category"][0] == "Video Effects" && e["folder"] != "Obsolete" && e["folder"] != Value::Null).count();
assert_eq!(n, 93, "Premiere 26's current Video Effects");
}
#[test]
fn every_video_effect_applies_by_id_and_name_and_renders() {
let mut s = demo();
let clip = first_v1_clip(&s);
for d in video_effects() {
s.execute("effects.apply", json!({"clips": [clip], "effect": d.id})).unwrap_or_else(|e| panic!("{}: {e}", d.id));
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone();
let inst = it.effects.iter().find(|e| e.effect == d.id).unwrap_or_else(|| panic!("{} not on the clip", d.id));
// auto points were resolved against the frame
for (k, p) in &inst.params {
if let Some(v) = p.value.as_vec2() {
assert!(v.x.is_finite() && v.y.is_finite(), "{}.{k} unresolved", d.id);
}
}
if !matches!(d.id, "warp_stabilizer" | "auto_reframe" | "echo") {
let img = s.render_program(0.125).unwrap();
assert!(img.px.iter().all(|v| v.is_finite()), "{} rendered non-finite pixels", d.id);
}
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effects.iter().all(|e| e.effect != d.id), "{} undo", d.id);
// by display name (MCP / CLI agents use either)
s.execute("effects.apply", json!({"clips": [clip], "effect": d.name})).unwrap_or_else(|e| panic!("{} by name: {e}", d.name));
s.execute("edit.undo", json!({})).unwrap();
}
}
#[test]
fn new_effect_params_are_settable_and_keyframable() {
let mut s = demo();
let clip = first_v1_clip(&s);
s.execute("effects.apply", json!({"clips": [clip], "effect": "Turbulent Displace"})).unwrap();
s.execute("effects.setParam", json!({"clip": clip, "effect": "turbulent_displace", "param": "amount", "value": 120.0})).unwrap();
s.execute("effects.toggleAnimation", json!({"clip": clip, "effect": "turbulent_displace", "param": "evolution"})).unwrap();
s.execute("effects.apply", json!({"clips": [clip], "effect": "simple_text"})).unwrap();
s.execute("effects.setParam", json!({"clip": clip, "effect": "simple_text", "param": "text", "value": "Hello"})).unwrap();
s.execute("effects.apply", json!({"clips": [clip], "effect": "corner_pin"})).unwrap();
s.execute("effects.setParam", json!({"clip": clip, "effect": "corner_pin", "param": "upper_left", "value": [100.0, 50.0]})).unwrap();
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone();
let td = it.effect("turbulent_displace").unwrap();
assert_eq!(td.param("amount").unwrap().value.as_f64(), Some(120.0));
assert!(td.param("evolution").unwrap().is_animated() || !td.param("evolution").unwrap().keyframes.is_empty());
assert!(matches!(&it.effect("simple_text").unwrap().param("text").unwrap().value, filmcraft_project::ParamValue::Text(t) if t == "Hello"));
let img = s.render_program(0.125).unwrap();
assert!(img.px.iter().all(|v| v.is_finite()));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,8 @@
asset: vfx_distort.png
title: Corner Pin over Turbulent Displace
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_distort). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,8 @@
asset: vfx_glows.png
title: Wonder Glow and Glint
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_glows). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,8 @@
asset: vfx_spacer_stroke_shadow.png
title: Spacer, Stroke and Long Shadow over a Gradient
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_spacer_stroke_shadow). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,8 @@
asset: vfx_stylize.png
title: Channel Mix, Color Emboss and Rounded Crop
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_stylize). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,8 @@
asset: vfx_ultra_key.png
title: Ultra Key on colour bars over footage
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_ultra_key). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,8 @@
asset: vfx_vr_rotate.png
title: VR Rotate Sphere
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-02 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (vfx_vr_rotate). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
+94 -1
View File
@@ -460,6 +460,87 @@ fn mask_opacity() -> Scene {
b.at(12)
}
/// M5.11 Distort: Corner Pin (a demo picture pinned into a quad) over Turbulent-Displaced bars.
fn vfx_distort() -> Scene {
let mut b = Builder::new(2);
let bars = b.media(Generator::BarsAndTone);
let fg = b.demo(DemoScene::Dunes);
let c = b.place(0, bars, 0, 48);
b.effect(c, "turbulent_displace", &[("amount", fl(40.0)), ("size", fl(80.0)), ("complexity", fl(2.0))]);
let top = b.place(1, fg, 0, 48);
b.effect(
top,
"corner_pin",
&[("upper_left", pt(120.0, 60.0)), ("upper_right", pt(560.0, 20.0)), ("lower_left", pt(60.0, 330.0)), ("lower_right", pt(520.0, 300.0))],
);
b.at(12)
}
/// M5.11 Keying: Ultra Key removing the bars' green over a demo background, with spill suppression.
fn vfx_ultra_key() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let bars = b.media(Generator::BarsAndTone);
b.place(0, bg, 0, 48);
let top = b.place(1, bars, 0, 48);
b.effect(top, "ultra_key", &[("key_color", ParamValue::Color([0.0, 0.75, 0.0, 1.0]))]);
b.at(12)
}
/// M5.11 Lights & Glows: Wonder Glow and Glint on a night city.
fn vfx_glows() -> Scene {
let mut b = Builder::new(1);
let city = b.demo(DemoScene::CityNight);
let c = b.place(0, city, 0, 48);
b.effect(c, "wonder_glow", &[("threshold", fl(55.0)), ("radius", fl(20.0))]);
b.effect(c, "glint", &[("threshold", fl(80.0)), ("length", fl(40.0))]);
b.at(12)
}
/// M5.11 Utility / Perspective: Spacer inset with rounded corners, an inside Stroke and a fading
/// Long Shadow, over a Gradient generator.
fn vfx_spacer_stroke_shadow() -> Scene {
let mut b = Builder::new(2);
let bg = b.media(Generator::BlackVideo);
let fg = b.demo(DemoScene::Forest);
let back = b.place(0, bg, 0, 48);
b.effect(
back,
"gradient",
&[
("start", pt(0.0, 0.0)),
("end", pt(640.0, 360.0)),
("start_color", ParamValue::Color([0.15, 0.2, 0.45, 1.0])),
("end_color", ParamValue::Color([0.9, 0.6, 0.3, 1.0])),
],
);
let top = b.place(1, fg, 0, 48);
b.effect(top, "spacer", &[("left", fl(90.0)), ("radius", fl(30.0))]);
b.effect(top, "stroke", &[("width", fl(6.0)), ("position", ParamValue::Choice(2))]);
b.effect(top, "long_shadow", &[("length", fl(80.0)), ("opacity", fl(70.0))]);
b.at(12)
}
/// M5.11 Immersive Video: VR Rotate Sphere (pan and tilt) on equirectangular-treated aurora.
fn vfx_vr_rotate() -> Scene {
let mut b = Builder::new(1);
let aur = b.demo(DemoScene::Aurora);
let c = b.place(0, aur, 0, 48);
b.effect(c, "vr_rotate_sphere", &[("pan", fl(90.0)), ("tilt", fl(25.0)), ("roll", fl(10.0))]);
b.at(12)
}
/// M5.11 Stylize / Image Control: Rounded Crop, Color Emboss and Channel Mix on plasma.
fn vfx_stylize() -> Scene {
let mut b = Builder::new(1);
let pl = b.demo(DemoScene::Plasma);
let c = b.place(0, pl, 0, 48);
b.effect(c, "channel_mix", &[("rr", fl(0.0)), ("rb", fl(100.0)), ("bb", fl(0.0)), ("br", fl(100.0))]);
b.effect(c, "color_emboss", &[("relief", fl(3.0)), ("contrast", fl(150.0))]);
b.effect(c, "rounded_crop", &[("left", fl(8.0)), ("right", fl(8.0)), ("top", fl(8.0)), ("bottom", fl(8.0)), ("radius", fl(50.0)), ("border", fl(4.0))]);
b.at(12)
}
/// (name, title, scene).
fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
vec![
@@ -480,6 +561,12 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
("mask_color", "Masks: Black & White inside an expanded polygon minus a feathered Bezier mask", mask_color),
("mask_inverted", "Masks: Mosaic outside an inverted ellipse mask at 80% opacity", mask_inverted),
("mask_opacity", "Masks: feathered ellipse opacity mask on a rotated top layer", mask_opacity),
("vfx_distort", "Corner Pin over Turbulent Displace", vfx_distort),
("vfx_ultra_key", "Ultra Key on colour bars over footage", vfx_ultra_key),
("vfx_glows", "Wonder Glow and Glint", vfx_glows),
("vfx_spacer_stroke_shadow", "Spacer, Stroke and Long Shadow over a Gradient", vfx_spacer_stroke_shadow),
("vfx_vr_rotate", "VR Rotate Sphere", vfx_vr_rotate),
("vfx_stylize", "Channel Mix, Color Emboss and Rounded Crop", vfx_stylize),
]
}
@@ -538,7 +625,13 @@ goldens!(
mask_blur,
mask_color,
mask_inverted,
mask_opacity
mask_opacity,
vfx_distort,
vfx_ultra_key,
vfx_glows,
vfx_spacer_stroke_shadow,
vfx_vr_rotate,
vfx_stylize
);
/// Sanity: the scenes are not trivially empty or identical to each other.
+26 -20
View File
@@ -16,6 +16,9 @@ mod audio;
use crate::mask::Mask;
pub use audio::{GEQ10_LABELS, GEQ20_LABELS, GEQ30_LABELS, PREMIERE_AUDIO_EFFECTS};
mod vfx;
pub use vfx::{EASINGS, ECHO_OPERATORS, FRAME_LAYOUTS, LIGHT_IDS, SIMPLE_BLEND, TRACK_CHOICES, auto_point};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectKind {
Video,
@@ -355,7 +358,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"color_balance",
"Color Balance",
COLOR_CORR,
vfx::OBSOLETE,
vec![
f("shadow_r", "Shadow Red Balance", 0.0, -100.0, 100.0, ""),
f("shadow_g", "Shadow Green Balance", 0.0, -100.0, 100.0, ""),
@@ -372,7 +375,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"leave_color",
"Leave Color",
COLOR_CORR,
vfx::OBSOLETE,
vec![
f("amount", "Amount to Decolor", 0.0, 0.0, 100.0, "%"),
col("color", "Color To Leave", [1.0, 0.0, 0.0, 1.0]),
@@ -383,7 +386,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"change_to_color",
"Change to Color",
COLOR_CORR,
vfx::OBSOLETE,
vec![
col("from", "From", [1.0, 0.0, 0.0, 1.0]),
col("to", "To", [0.0, 0.0, 1.0, 1.0]),
@@ -488,7 +491,7 @@ fn build_effects() -> Vec<EffectDef> {
fs("threshold", "Threshold", 0.0, (0.0, 255.0), (0.0, 255.0), "", 0),
],
),
video("camera_blur", "Camera Blur", BLUR, vec![f("percent", "Percent Blur", 0.0, 0.0, 100.0, "")]),
video("camera_blur", "Camera Blur", vfx::LEGACY, vec![f("percent", "Percent Blur", 0.0, 0.0, 100.0, "")]),
video("black_white", "Black & White", IMAGE_CONTROL, vec![]),
video(
"color_pass",
@@ -500,7 +503,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"invert",
"Invert",
&["Video Effects", "Channel"],
IMAGE_CONTROL,
vec![ch("channel", "Channel", &["RGB", "Red", "Green", "Blue", "Alpha"], 0), f("blend", "Blend With Original", 0.0, 0.0, 100.0, "%")],
),
video(
@@ -552,7 +555,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"emboss",
"Emboss",
STYLIZE,
vfx::OBSOLETE,
vec![
ang("direction", "Direction", 45.0),
fs("relief", "Relief", 1.8, (0.0, 10.0), (0.0, 10.0), "", 1),
@@ -560,7 +563,7 @@ fn build_effects() -> Vec<EffectDef> {
f("blend", "Blend With Original", 0.0, 0.0, 100.0, "%"),
],
),
video("replicate", "Replicate", STYLIZE, vec![fs("count", "Count", 2.0, (2.0, 16.0), (2.0, 16.0), "", 0)]),
video("replicate", "Replicate", vfx::LEGACY, vec![fs("count", "Count", 2.0, (2.0, 16.0), (2.0, 16.0), "", 0)]),
video(
"strobe",
"Strobe Light",
@@ -578,11 +581,11 @@ fn build_effects() -> Vec<EffectDef> {
NOISE,
vec![f("amount", "Amount of Noise", 0.0, 0.0, 100.0, "%"), b("color", "Use Color Noise", true), b("clip", "Clipping", true)],
),
video("median", "Median", NOISE, vec![fs("radius", "Radius", 0.0, (0.0, 100.0), (0.0, 20.0), "", 0)]),
video("median", "Median", vfx::OBSOLETE, vec![fs("radius", "Radius", 0.0, (0.0, 100.0), (0.0, 20.0), "", 0)]),
video(
"crop",
"Crop",
TRANSFORM,
vfx::LEGACY,
vec![
f("left", "Left", 0.0, 0.0, 100.0, "%"),
f("top", "Top", 0.0, 0.0, 100.0, "%"),
@@ -594,11 +597,11 @@ fn build_effects() -> Vec<EffectDef> {
),
video("horizontal_flip", "Horizontal Flip", TRANSFORM, vec![]),
video("vertical_flip", "Vertical Flip", TRANSFORM, vec![]),
video("edge_feather", "Edge Feather", TRANSFORM, vec![fs("amount", "Amount", 0.0, (0.0, 100.0), (0.0, 100.0), "", 0)]),
video("edge_feather", "Edge Feather", vfx::LEGACY, vec![fs("amount", "Amount", 0.0, (0.0, 100.0), (0.0, 100.0), "", 0)]),
video(
"transform",
"Transform",
DISTORT,
TRANSFORM,
vec![
pt("anchor", "Anchor Point", f64::NAN, f64::NAN),
pt("position", "Position", f64::NAN, f64::NAN),
@@ -613,7 +616,7 @@ fn build_effects() -> Vec<EffectDef> {
],
),
video("mirror", "Mirror", DISTORT, vec![pt("center", "Reflection Center", f64::NAN, f64::NAN), ang("angle", "Reflection Angle", 0.0)]),
video("offset", "Offset", DISTORT, vec![pt("shift", "Shift Center To", f64::NAN, f64::NAN), f("blend", "Blend With Original", 0.0, 0.0, 100.0, "%")]),
video("offset", "Offset", TRANSFORM, vec![pt("shift", "Shift Center To", f64::NAN, f64::NAN), f("blend", "Blend With Original", 0.0, 0.0, 100.0, "%")]),
video(
"lens_distortion",
"Lens Distortion",
@@ -667,7 +670,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"bevel_alpha",
"Bevel Alpha",
PERSPECTIVE,
vfx::OBSOLETE,
vec![
fs("thickness", "Edge Thickness", 2.0, (0.0, 200.0), (0.0, 10.0), "", 1),
ang("angle", "Light Angle", -60.0),
@@ -731,7 +734,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"ramp",
"Ramp",
GENERATE,
vfx::LEGACY,
vec![
pt("start", "Start of Ramp", f64::NAN, 0.0),
col("start_color", "Start Color", [0.0, 0.0, 0.0, 1.0]),
@@ -744,7 +747,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"circle",
"Circle",
GENERATE,
vfx::OBSOLETE,
vec![
pt("center", "Center", f64::NAN, f64::NAN),
fs("radius", "Radius", 75.0, (0.0, 4000.0), (0.0, 400.0), "", 1),
@@ -755,7 +758,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"grid",
"Grid",
GENERATE,
vfx::OBSOLETE,
vec![
fs("size", "Width", 60.0, (1.0, 4000.0), (1.0, 400.0), "", 0),
fs("border", "Border", 2.0, (0.0, 100.0), (0.0, 20.0), "", 1),
@@ -766,7 +769,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"lens_flare",
"Lens Flare",
GENERATE,
vfx::LIGHTS,
vec![
pt("center", "Flare Center", f64::NAN, f64::NAN),
f("brightness", "Flare Brightness", 100.0, 0.0, 300.0, "%"),
@@ -776,7 +779,7 @@ fn build_effects() -> Vec<EffectDef> {
video(
"timecode",
"Timecode",
VIDEO,
vfx::OBSOLETE,
vec![
pt("position", "Position", f64::NAN, f64::NAN),
fs("size", "Size", 15.0, (1.0, 100.0), (1.0, 50.0), "%", 1),
@@ -786,10 +789,10 @@ fn build_effects() -> Vec<EffectDef> {
video(
"clip_name",
"Clip Name",
VIDEO,
vfx::OBSOLETE,
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("simple_text", "Simple Text", vfx::UTILITY, vec![]),
// ---- video transitions ----
vtrans("cross_dissolve", "Cross Dissolve", DISSOLVE, vec![]),
vtrans("additive_dissolve", "Additive Dissolve", DISSOLVE, vec![]),
@@ -858,6 +861,8 @@ fn build_effects() -> Vec<EffectDef> {
},
];
v.extend(audio::defs());
v.extend(vfx::defs());
vfx::extend_core(&mut v);
v.extend(crate::graphic::layer_defs());
// YUV badge for the colour/intrinsic set
for e in &mut v {
@@ -865,6 +870,7 @@ fn build_effects() -> Vec<EffectDef> {
e.yuv = true;
}
}
vfx::apply_badges(&mut v);
v
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1265,6 +1265,14 @@ pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u
for (k, p) in e.params.iter_mut() {
if let ParamValue::Vec2(v) = &mut p.value {
let (w, h) = if k == "anchor" { source } else { frame };
if let Some((fx, fy)) = effect::auto_point(&e.effect, k) {
if v.x.is_nan() {
v.x = w as f64 * fx;
}
if v.y.is_nan() {
v.y = h as f64 * fy;
}
}
if v.x.is_nan() {
v.x = w as f64 / 2.0;
}
+2 -2
View File
@@ -184,7 +184,7 @@ fn set_params(base: &EffectInstance, x: &[f32; 10]) -> EffectInstance {
fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect: bool) -> Stats {
let mut g = img.clone();
let cx = FxCtx { t: Tick::ZERO, px_scale: g.w as f32 / 1920.0, seconds: 0.0, timecode: "", clip_name: "", project: None };
let cx = FxCtx { t: Tick::ZERO, px_scale: g.w as f32 / 1920.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None };
apply(&mut g, &set_params(base, x), &cx);
stats(&g, skin_protect)
}
@@ -344,7 +344,7 @@ mod tests {
fn graded(img: &Image, base: &EffectInstance, x: [f32; 10]) -> Image {
let mut g = img.clone();
let cx = FxCtx { t: Tick::ZERO, px_scale: 0.1, seconds: 0.0, timecode: "", clip_name: "", project: None };
let cx = FxCtx { t: Tick::ZERO, px_scale: 0.1, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None };
apply(&mut g, &set_params(base, &x), &cx);
g
}
+22 -13
View File
@@ -26,6 +26,9 @@ pub struct FxCtx<'a> {
pub clip_name: &'a str,
/// The project (LUT library for Lumetri); `None` in isolated effect tests.
pub project: Option<&'a filmcraft_project::Project>,
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
pub env: Option<&'a dyn crate::vfx::FxEnv>,
}
fn f(e: &EffectInstance, id: &str, cx: &FxCtx) -> f32 {
@@ -63,15 +66,15 @@ fn point(e: &EffectInstance, id: &str, cx: &FxCtx, img: &Image) -> Vec2 {
}
#[inline]
fn enc(c: [f32; 3]) -> [f32; 3] {
pub(crate) fn enc(c: [f32; 3]) -> [f32; 3] {
[linear_to_srgb(c[0].max(0.0)), linear_to_srgb(c[1].max(0.0)), linear_to_srgb(c[2].max(0.0))]
}
#[inline]
fn dec(c: [f32; 3]) -> [f32; 3] {
pub(crate) fn dec(c: [f32; 3]) -> [f32; 3] {
[srgb_to_linear(c[0].clamp(0.0, 1.0)), srgb_to_linear(c[1].clamp(0.0, 1.0)), srgb_to_linear(c[2].clamp(0.0, 1.0))]
}
fn hash3(x: usize, y: usize, z: u64) -> f32 {
pub(crate) fn hash3(x: usize, y: usize, z: u64) -> f32 {
let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ z.wrapping_mul(0x1656_67B1_9E37_79F9);
h ^= h >> 31;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
@@ -84,6 +87,9 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
if !e.enabled || img.w == 0 || img.h == 0 {
return;
}
if crate::vfx::apply(img, e, cx) {
return;
}
match e.effect.as_str() {
"brightness_contrast" => {
let br = f(e, "brightness", cx) / 100.0 * 0.4;
@@ -739,7 +745,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
}
/// Fill an opaque generated colour over the image at `op` opacity (Generate category).
fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync) {
pub(crate) fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync) {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
@@ -753,11 +759,11 @@ fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync)
});
}
fn warp(img: &mut Image, f: impl Fn(f64, f64) -> (f64, f64) + Sync) {
pub(crate) fn warp(img: &mut Image, f: impl Fn(f64, f64) -> (f64, f64) + Sync) {
warp_opt(img, |x, y| Some(f(x, y)));
}
fn warp_opt(img: &mut Image, f: impl Fn(f64, f64) -> Option<(f64, f64)> + Sync) {
pub(crate) fn warp_opt(img: &mut Image, f: impl Fn(f64, f64) -> Option<(f64, f64)> + Sync) {
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
@@ -771,7 +777,7 @@ fn warp_opt(img: &mut Image, f: impl Fn(f64, f64) -> Option<(f64, f64)> + Sync)
});
}
fn crop(img: &mut Image, l: f32, t: f32, r: f32, b: f32, feather: f32) {
pub(crate) fn crop(img: &mut Image, l: f32, t: f32, r: f32, b: f32, feather: f32) {
let (w, h) = (img.w as f32, img.h as f32);
let (x0, x1, y0, y1) = (l * w, w * (1.0 - r), t * h, h * (1.0 - b));
let fe = feather.max(0.0);
@@ -791,7 +797,7 @@ fn crop(img: &mut Image, l: f32, t: f32, r: f32, b: f32, feather: f32) {
});
}
fn key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
pub(crate) fn key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let ultra = e.effect == "ultra_key";
let kc = if ultra { color(e, "key_color", cx) } else { color(e, "color", cx) };
let kycc = filmcraft_color::rgb_to_ycbcr(kc[0], kc[1], kc[2], filmcraft_color::Matrix::Bt709);
@@ -1006,6 +1012,9 @@ fn transpose(img: &Image) -> Image {
/// Gaussian blur via 3 box passes per axis (O(1) per pixel for any radius).
pub fn gaussian(img: &mut Image, sigma_x: f32, sigma_y: f32, repeat_edge: bool) {
// beyond a few image sizes every radius gives the same (flat) result; cap to keep box sizes sane
let sigma_x = if sigma_x.is_finite() { sigma_x.min(img.w.max(8) as f32 * 2.0) } else { 0.0 };
let sigma_y = if sigma_y.is_finite() { sigma_y.min(img.h.max(8) as f32 * 2.0) } else { 0.0 };
if sigma_x > 0.3 {
for r in boxes_for_gauss(sigma_x, 3) {
box_rows(&mut img.px, img.w, r, repeat_edge);
@@ -1020,7 +1029,7 @@ pub fn gaussian(img: &mut Image, sigma_x: f32, sigma_y: f32, repeat_edge: bool)
}
}
fn unsharp(img: &mut Image, radius: f32, amount: f32, threshold: f32) {
pub(crate) fn unsharp(img: &mut Image, radius: f32, amount: f32, threshold: f32) {
let mut blurred = img.clone();
gaussian(&mut blurred, radius, radius, true);
img.px.par_chunks_mut(4).zip(blurred.px.par_chunks(4)).for_each(|(p, bq)| {
@@ -1034,7 +1043,7 @@ fn unsharp(img: &mut Image, radius: f32, amount: f32, threshold: f32) {
});
}
fn directional_blur(img: &mut Image, len: f32, dir: f64) {
pub(crate) fn directional_blur(img: &mut Image, len: f32, dir: f64) {
if len < 0.5 {
return;
}
@@ -1059,7 +1068,7 @@ fn directional_blur(img: &mut Image, len: f32, dir: f64) {
});
}
fn median(img: &mut Image, r: isize) {
pub(crate) fn median(img: &mut Image, r: isize) {
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each_init(Vec::new, |buf: &mut Vec<f32>, (y, row)| {
@@ -1079,7 +1088,7 @@ fn median(img: &mut Image, r: isize) {
});
}
fn mosaic(img: &mut Image, bx: usize, by: usize) {
pub(crate) fn mosaic(img: &mut Image, bx: usize, by: usize) {
let (w, h) = (img.w, img.h);
let bw = (w as f32 / bx as f32).max(1.0);
let bh = (h as f32 / by as f32).max(1.0);
@@ -1100,7 +1109,7 @@ mod tests {
use filmcraft_project::find_effect;
fn cx() -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:01:00", clip_name: "x", project: None }
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:01:00", clip_name: "x", project: None, env: None }
}
#[test]
+70 -41
View File
@@ -25,6 +25,7 @@ pub mod plan;
pub mod preview;
pub mod track;
pub mod transitions;
pub mod vfx;
use std::sync::Arc;
@@ -141,6 +142,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
timecode: &tc,
clip_name: &item.name,
project: Some(project),
env: None,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adjusted, e, &cx);
@@ -276,9 +278,8 @@ pub(crate) fn item_layer(
tc: &str,
) -> Option<(Image, f32, Blend)> {
let (w, h) = output_size(seq, opts.scale);
// `ft`: the media time of the frame shown; `mt`: where effects and Motion/Opacity are evaluated
// (they differ inside a frame hold without Hold Filters).
let ft = item.source_time_at(t);
// `mt`: where effects and Motion/Opacity are evaluated (differs from the shown frame's media
// time inside a frame hold without Hold Filters).
let mt = item.effect_time_at(t);
let src_size = source_size(project, item.item)?;
let motion = motion_matrix(seq, item, src_size, mt);
@@ -286,7 +287,71 @@ pub(crate) fn item_layer(
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
let pi = project.item(item.item)?;
let mut layer = match &pi.kind {
if matches!(pi.kind, ItemKind::Graphic { .. }) && !(item.has_opacity_masks() || opts.effects && item.has_standard_effects()) {
// vectors straight to the output: no resampling, crisp at any Motion scale
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion);
let mut canvas = Image::new(w, h);
graphic_clip::render_graphic(&item.effects, mt, src_size, &m, &mut canvas);
let (op, bl) = opacity_blend(item, mt);
return Some((canvas, op, bl));
}
let mut layer = base_layer(project, seq, item, t, opts, sources, want)?;
let px_scale = layer.w as f32 / src_size.0.max(1) as f32;
// layer px → source px → sequence px → output px
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64));
if opts.effects {
let env = vfx::ItemEnv { project, seq, item, t, opts, sources, want, layer_size: (layer.w, layer.h), layer_to_output: m, tc };
let cx = effects::FxCtx {
t: mt,
px_scale,
seconds: (t - item.start).seconds(),
timecode: tc,
clip_name: &item.name,
project: Some(project),
env: Some(&env),
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
if filmcraft_media::cancel::cancelled() {
return None;
}
mask::apply_effect(&mut layer, e, &cx);
}
}
mask::apply_opacity_masks(&mut layer, item, mt, px_scale);
let placed = if layer.w == w
&& layer.h == h
&& (m.a - 1.0).abs() < 1e-9
&& (m.d - 1.0).abs() < 1e-9
&& m.b == 0.0
&& m.c == 0.0
&& m.e.abs() < 1e-9
&& m.f.abs() < 1e-9
{
layer
} else {
layer.transformed(w, h, &m)
};
let (op, bl) = opacity_blend(item, mt);
Some((placed, op, bl))
}
/// A track item's picture at timeline `t` before any effect (decoded, colour managed, frame
/// blended; nested sequences rendered; graphics rasterised), at `want` of its source size.
pub(crate) fn base_layer(
project: &Project,
seq: &Sequence,
item: &TrackItem,
t: Tick,
opts: RenderOptions,
sources: &dyn SourceProvider,
want: f32,
) -> Option<Image> {
// `ft`: the media time of the frame shown
let ft = item.source_time_at(t);
let mt = item.effect_time_at(t);
let src_size = source_size(project, item.item)?;
let pi = project.item(item.item)?;
Some(match &pi.kind {
ItemKind::Media(_) | ItemKind::Subclip { .. } => {
let src = sources.source(item.item)?;
let frame = src.video_frame(FrameRequest { time: ft, scale: want }).ok()?;
@@ -319,49 +384,13 @@ pub(crate) fn item_layer(
}
ItemKind::AdjustmentLayer { .. } => return None,
ItemKind::Graphic { .. } => {
if !(item.has_opacity_masks() || opts.effects && item.has_standard_effects()) {
// vectors straight to the output: no resampling, crisp at any Motion scale
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion);
let mut canvas = Image::new(w, h);
graphic_clip::render_graphic(&item.effects, mt, src_size, &m, &mut canvas);
let (op, bl) = opacity_blend(item, mt);
return Some((canvas, op, bl));
}
// standard effects work on the graphic at source resolution, then Motion places it
let (gw, gh) = (((src_size.0 as f32 * want).ceil() as usize).max(1), ((src_size.1 as f32 * want).ceil() as usize).max(1));
let mut img = Image::new(gw, gh);
graphic_clip::render_graphic(&item.effects, mt, src_size, &Affine::scale(want as f64, want as f64), &mut img);
img
}
};
let px_scale = layer.w as f32 / src_size.0.max(1) as f32;
if opts.effects {
let cx = effects::FxCtx { t: mt, px_scale, seconds: (t - item.start).seconds(), timecode: tc, clip_name: &item.name, project: Some(project) };
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
if filmcraft_media::cancel::cancelled() {
return None;
}
mask::apply_effect(&mut layer, e, &cx);
}
}
mask::apply_opacity_masks(&mut layer, item, mt, px_scale);
// layer px → source px → sequence px → output px
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64));
let placed = if layer.w == w
&& layer.h == h
&& (m.a - 1.0).abs() < 1e-9
&& (m.d - 1.0).abs() < 1e-9
&& m.b == 0.0
&& m.c == 0.0
&& m.e.abs() < 1e-9
&& m.f.abs() < 1e-9
{
layer
} else {
layer.transformed(w, h, &m)
};
let (op, bl) = opacity_blend(item, mt);
Some((placed, op, bl))
})
}
/// The layer of one clip at timeline `t` with its effects applied, on a canvas the size of the
+22 -1
View File
@@ -386,7 +386,28 @@ pub fn effect_cost_ms(id: &str) -> f64 {
| "gaussian_blur"
| "directional_blur"
| "camera_blur"
| "median" => 50.0,
| "median"
// M5.11 (multi-pass, multi-frame or analysis effects)
| "bokeh_blur"
| "compound_blur"
| "focus_blur"
| "channel_blur"
| "turbulent_displace"
| "warp_stabilizer"
| "auto_reframe"
| "echo"
| "echo_glow"
| "glint"
| "wonder_glow"
| "edge_glow"
| "volumetric_rays"
| "brush_strokes"
| "roughen_edges"
| "lighting_effects"
| "vr_blur"
| "vr_glow"
| "vr_denoise"
| "vr_sharpen" => 50.0,
"vertical_flip" | "horizontal_flip" | "crop" | "timecode" | "black_white" | "extract" | "clip_name" | "strobe" | "mosaic" | "mirror"
| "simple_text" | "track_matte" => 6.0,
_ => 20.0,
+1 -1
View File
@@ -226,7 +226,7 @@ fn effect_costs() {
for d in filmcraft_project::effect_defs().iter().filter(|d| d.kind == filmcraft_project::EffectKind::Video && !d.intrinsic) {
let mut img = crate::Image::filled(1920, 1080, [0.4, 0.5, 0.6, 1.0]);
let e = d.instance();
let cx = FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:00:00", clip_name: "x", project: None };
let cx = FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:00:00", clip_name: "x", project: None, env: None };
apply(&mut img, &e, &cx);
let t0 = std::time::Instant::now();
for _ in 0..3 {
+195
View File
@@ -0,0 +1,195 @@
//! Blur & Sharpen effects.
use filmcraft_project::EffectInstance;
use rayon::prelude::*;
use super::*;
use crate::effects::gaussian;
/// Radius of a regular `n`-gon (circumradius 1, rotated by `rot`) in direction `theta`;
/// `n == 0` is a circle.
fn poly_radius(n: u32, theta: f32, rot: f32) -> f32 {
if n == 0 {
return 1.0;
}
let seg = std::f32::consts::TAU / n as f32;
let a = (theta - rot).rem_euclid(seg) - seg / 2.0;
(std::f32::consts::PI / n as f32).cos() / a.cos()
}
/// Bokeh Blur: a lens-iris shaped gather blur with highlight boost (bright points bloom into
/// iris-shaped discs). Large radii run on a reduced image (the result is smooth).
pub fn bokeh(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let r = fv(e, "amount", cx) * cx.px_scale;
if r < 0.5 {
return;
}
let sides = [0u32, 3, 4, 5, 6, 8][chv(e, "shape").min(5) as usize];
let rot = fv(e, "rotation", cx).to_radians();
let boost = fv(e, "highlight", cx) / 100.0 * 6.0;
let th = fv(e, "threshold", cx) / 100.0;
let (fw, fh) = (img.w, img.h);
let mut work = img.clone();
let mut down = 1.0f32;
while r / down > 12.0 && work.w >= 8 && work.h >= 8 {
work = work.downsample2();
down *= 2.0;
}
let rr = r / down;
// boost highlights (energy, not weight: bright points stay bright after averaging)
if boost > 0.0 {
work.px.par_chunks_mut(4).for_each(|p| {
if p[3] <= 1e-6 {
return;
}
let l = luma(enc(Image::unpremul([p[0], p[1], p[2], p[3]])));
let k = 1.0 + boost * smoothstep(th, 1.0, l);
for v in &mut p[..3] {
*v *= k;
}
});
}
let rings = (rr.ceil() as usize).clamp(1, 12);
let mut taps: Vec<(f32, f32)> = vec![(0.0, 0.0)];
for ring in 1..=rings {
let rad = rr * ring as f32 / rings as f32;
let n = (ring * 8).max(6);
for i in 0..n {
let th = std::f32::consts::TAU * (i as f32 + 0.5 * (ring % 2) as f32) / n as f32;
let pr = rad * poly_radius(sides, th, rot);
taps.push((th.cos() * pr, th.sin() * pr));
}
}
let inv = 1.0 / taps.len() as f32;
let src = work.clone();
let w = work.w;
work.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let mut acc = [0f32; 4];
for &(dx, dy) in &taps {
let p = src.sample_bilinear_clamped(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
for k in 0..4 {
acc[k] += p[k];
}
}
for k in 0..4 {
row[x * 4 + k] = acc[k] * inv;
}
}
});
*img = if down > 1.0 { work.transformed(fw, fh, &Affine::scale(fw as f64 / work.w as f64, fh as f64 / work.h as f64)) } else { work };
}
/// Blur one channel of an image (0..3) with a Gaussian.
fn blur_channel(img: &mut Image, ch: usize, sx: f32, sy: f32, repeat: bool) {
if sx <= 0.3 && sy <= 0.3 {
return;
}
let mut plane = Image { w: img.w, h: img.h, px: img.px.chunks_exact(4).flat_map(|p| [p[ch], 0.0, 0.0, 0.0]).collect() };
gaussian(&mut plane, sx, sy, repeat);
img.px.par_chunks_mut(4).zip(plane.px.par_chunks(4)).for_each(|(p, q)| p[ch] = q[0]);
}
/// Channel Blur: independent Gaussian blur per R, G, B and alpha.
pub fn channel_blur(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let dims = chv(e, "dimensions");
let repeat = bv(e, "repeat_edge");
for (ch, id) in ["red", "green", "blue", "alpha"].iter().enumerate() {
let s = fv(e, id, cx) * cx.px_scale * 0.5;
blur_channel(img, ch, if dims == 2 { 0.0 } else { s }, if dims == 1 { 0.0 } else { s }, repeat);
}
}
/// Mix between a stack of increasingly blurred copies by a per-pixel amount 0..1.
fn variable_blur(img: &mut Image, max_sigma: f32, amount: impl Fn(usize, usize) -> f32 + Sync) {
if max_sigma <= 0.3 {
return;
}
let levels = [0.0f32, 0.125, 0.25, 0.5, 1.0];
let stack: Vec<Image> = levels
.par_iter()
.map(|&k| {
let mut c = img.clone();
if k > 0.0 {
gaussian(&mut c, max_sigma * k, max_sigma * k, true);
}
c
})
.collect();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let a = amount(x, y).clamp(0.0, 1.0);
let i = levels.partition_point(|&l| l <= a).clamp(1, levels.len() - 1);
let (l0, l1) = (levels[i - 1], levels[i]);
let t = ((a - l0) / (l1 - l0)).clamp(0.0, 1.0);
let (p0, p1) = (stack[i - 1].get(x, y), stack[i].get(x, y));
for k in 0..4 {
row[x * 4 + k] = p0[k] + (p1[k] - p0[k]) * t;
}
}
});
}
/// Compound Blur: blur amount per pixel from a blur layer's luminance (another track, or this
/// layer's own luminance when the Blur Layer is None).
pub fn compound(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let max = fv(e, "max", cx) * cx.px_scale * 0.5;
let invert = bv(e, "invert");
let layer = chv(e, "layer") as usize;
let track = if layer > 0 { cx.env.and_then(|env| env.track(layer - 1).map(|t| (t, env.layer_to_output()))) } else { None };
let stretch = bv(e, "stretch");
let own = img.clone();
let (iw, ih) = (img.w as f64, img.h as f64);
variable_blur(img, max, |x, y| {
let l = match &track {
Some((t, m)) => {
let p = if stretch {
m.apply(Vec2::new(x as f64 + 0.5, y as f64 + 0.5))
} else {
Vec2::new((x as f64 + 0.5) / iw * t.w as f64, (y as f64 + 0.5) / ih * t.h as f64)
};
let s = t.sample_bilinear_clamped(p.x as f32, p.y as f32);
filmcraft_color::linear_to_srgb(luma([s[0], s[1], s[2]]).clamp(0.0, 1.0))
}
None => {
let s = own.get(x, y);
filmcraft_color::linear_to_srgb(luma([s[0], s[1], s[2]]).clamp(0.0, 1.0))
}
};
if invert { 1.0 - l } else { l }
});
}
/// Focus Blur: sharp inside a radial or linear focus area, blurring smoothly outside it.
pub fn focus(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let amount = fv(e, "amount", cx) * cx.px_scale * 0.5;
let c = pv(e, "center", cx, img);
let linear = chv(e, "shape") == 1;
let (s, co) = (fv(e, "angle", cx) as f64).to_radians().sin_cos();
let size = fv(e, "size", cx) as f64 * cx.px_scale as f64 * 0.5;
let feather = (fv(e, "feather", cx) as f64 * cx.px_scale as f64).max(1.0);
let show = bv(e, "show");
let dist = |x: usize, y: usize| -> f32 {
let (dx, dy) = (x as f64 + 0.5 - c.x, y as f64 + 0.5 - c.y);
let d = if linear { (-dx * s + dy * co).abs() } else { dx.hypot(dy) };
(((d - size) / feather).clamp(0.0, 1.0)) as f32
};
variable_blur(img, amount, dist);
if show {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let k = (1.0 - dist(x, y)) * 0.35;
let p = &mut row[x * 4..x * 4 + 4];
over(p, [k, 0.0, 0.0, k]);
}
});
}
}
/// Reduce Interlace Flicker: a vertical-only softening across fields.
pub fn interlace_flicker(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let s = fv(e, "softness", cx) * cx.px_scale.max(0.25);
gaussian(img, 0.0, s, true);
}
+537
View File
@@ -0,0 +1,537 @@
//! Colour, keying and matte effects.
use filmcraft_color::{Matrix, hsl_to_rgb, rgb_to_hsl, rgb_to_ycbcr};
use filmcraft_project::EffectInstance;
use filmcraft_project::effect::LIGHT_IDS;
use rayon::prelude::*;
use super::*;
/// Lighting Effects: up to five directional / omni / spot lights, ambience, gloss and a bump map
/// from the layer's own luminance.
pub fn lighting(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
struct L {
kind: u32,
color: [f32; 3],
c: Vec2,
major: f64,
minor: f64,
rot: (f64, f64),
intensity: f32,
focus: f32,
}
let diag = (img.w as f64).hypot(img.h as f64);
let lights: Vec<L> = LIGHT_IDS
.iter()
.map(|ids| L {
kind: chv(e, ids[0]),
color: lin(cv(e, ids[1], cx)),
c: pv(e, ids[2], cx, img),
major: (fv(e, ids[3], cx) as f64 / 100.0 * diag).max(1.0),
minor: (fv(e, ids[4], cx) as f64 / 100.0 * diag).max(1.0),
rot: (fv(e, ids[5], cx) as f64).to_radians().sin_cos(),
intensity: fv(e, ids[6], cx) / 20.0,
focus: fv(e, ids[7], cx) / 100.0,
})
.filter(|l| l.kind != 0)
.collect();
let amb = lin(cv(e, "ambient_color", cx)).map(|v| v * fv(e, "ambient", cx) / 100.0);
let gloss = fv(e, "gloss", cx) / 100.0;
let material = (fv(e, "material", cx) / 100.0 + 1.0) / 2.0;
let exposure = 2f32.powf(fv(e, "exposure", cx) / 100.0 * 2.0);
let bump = fv(e, "bump_height", cx) / 100.0 * 4.0;
let white_high = bv(e, "white_high");
let src = img.clone();
let lum = |x: isize, y: isize| {
let p = src.get_clamped(x, y);
let l = luma(Image::unpremul(p));
if white_high { l } else { 1.0 - l }
};
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = &mut row[x * 4..x * 4 + 4];
if p[3] <= 1e-6 {
continue;
}
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let (gx, gy) = if bump != 0.0 {
(
(lum(x as isize + 1, y as isize) - lum(x as isize - 1, y as isize)) * bump,
(lum(x as isize, y as isize + 1) - lum(x as isize, y as isize - 1)) * bump,
)
} else {
(0.0, 0.0)
};
let mut light = amb;
let mut spec = [0.0f32; 3];
for l in &lights {
let (dx, dy) = (px - l.c.x, py - l.c.y);
let (s, c) = l.rot;
let (u, v) = (dx * c + dy * s, -dx * s + dy * c);
let fall = match l.kind {
1 => 1.0,
2 => {
let d = ((dx * dx + dy * dy).sqrt() / l.major) as f32;
(1.0 - d).max(0.0).powi(2)
}
_ => {
let d = ((u / l.major).powi(2) + (v / l.minor).powi(2)).sqrt() as f32;
1.0 - smoothstep(l.focus * 0.95, 1.0, d)
}
};
// bump shading: light direction in the image plane (towards the light), tilted 45°
let (lx, ly) = if l.kind == 1 { (l.rot.1 as f32, l.rot.0 as f32) } else { (-dx as f32, -dy as f32) };
let ln = (lx * lx + ly * ly).sqrt().max(1e-6);
let shade = (1.0 - (gx * lx + gy * ly) / ln).max(0.0);
for k in 0..3 {
light[k] += l.color[k] * l.intensity * fall * shade;
spec[k] += l.color[k] * gloss.max(0.0) * fall.powi(8) * l.intensity.max(0.0);
}
}
let c = Image::unpremul([p[0], p[1], p[2], p[3]]);
for k in 0..3 {
let s = spec[k] * (1.0 - material) + spec[k] * c[k] * material;
let v = ((c[k] * light[k] + s) * exposure).max(0.0);
p[k] = v * p[3];
}
}
});
}
/// ASC CDL: out = clamp(in × slope + offset)^power per channel, then saturation (Rec. 709 luma),
/// on display-encoded values (ASC CDL v1.2).
pub fn asc_cdl(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let s = [fv(e, "r_slope", cx), fv(e, "g_slope", cx), fv(e, "b_slope", cx)];
let o = [fv(e, "r_offset", cx), fv(e, "g_offset", cx), fv(e, "b_offset", cx)];
let pw = [fv(e, "r_power", cx), fv(e, "g_power", cx), fv(e, "b_power", cx)];
let sat = fv(e, "saturation", cx);
img.map_rgb(|c, _, _| dec(cdl(enc(c), s, o, pw, sat)));
}
pub(crate) fn cdl(c: [f32; 3], s: [f32; 3], o: [f32; 3], p: [f32; 3], sat: f32) -> [f32; 3] {
let mut v = [0.0; 3];
for k in 0..3 {
v[k] = (c[k] * s[k] + o[k]).clamp(0.0, 1.0).powf(p[k].max(0.0));
}
let l = luma(v);
v.map(|q| (l + sat * (q - l)).clamp(0.0, 1.0))
}
/// Video Limiter: keeps Y′CbCr (Rec. 709, display-encoded) within 0…Clip Level with an
/// exponential soft knee that starts `compression` below the limit.
pub fn video_limiter(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let max = 1.0 + chv(e, "clip_level") as f32 / 100.0;
let comp = [0.0, 0.03, 0.05, 0.10, 0.20][chv(e, "compression").min(4) as usize];
let axis = chv(e, "axis");
let warn = bv(e, "gamut_warning");
let wc = lin(cv(e, "warning_color", cx));
img.map_rgb(|c, _, _| {
let v = [linear_to_enc(c[0]), linear_to_enc(c[1]), linear_to_enc(c[2])];
let out = limit(v, max, comp, axis);
if warn && v.iter().zip(&out).any(|(a, b)| (a - b).abs() > 1e-4) {
return wc;
}
dec_unclamped(out)
});
let _ = cx;
}
/// sRGB encoding that keeps values above 1 (super-whites) instead of clamping them.
#[inline]
fn linear_to_enc(v: f32) -> f32 {
if v <= 1.0 { filmcraft_color::linear_to_srgb(v.max(0.0)) } else { 1.0 + (v - 1.0) / 2.4 }
}
#[inline]
fn enc_to_linear(v: f32) -> f32 {
if v <= 1.0 { filmcraft_color::srgb_to_linear(v.max(0.0)) } else { 1.0 + (v - 1.0) * 2.4 }
}
fn dec_unclamped(c: [f32; 3]) -> [f32; 3] {
c.map(enc_to_linear)
}
#[inline]
fn knee(v: f32, max: f32, comp: f32) -> f32 {
let k = max * (1.0 - comp);
if comp <= 0.0 || v <= k {
return v.min(max);
}
let r = max - k;
k + r * (1.0 - (-(v - k) / r).exp())
}
pub(crate) fn limit(v: [f32; 3], max: f32, comp: f32, axis: u32) -> [f32; 3] {
let m = Matrix::Bt709;
let ycc = rgb_to_ycbcr(v[0], v[1], v[2], m);
let (kr, kb) = (0.2126f32, 0.0722f32);
let to_rgb = |y: f32, cb: f32, cr: f32| {
let r = y + 2.0 * (1.0 - kr) * cr;
let b = y + 2.0 * (1.0 - kb) * cb;
let g = (y - kr * r - kb * b) / (1.0 - kr - kb);
[r, g, b]
};
let (mut y, mut cb, mut cr) = (ycc[0], ycc[1], ycc[2]);
if axis != 1 {
y = knee(y.max(0.0), max, comp);
}
if axis != 0 {
// scale chroma so every channel fits 0…max (the gamut of legal R′G′B′)
let rgb = to_rgb(y, cb, cr);
let mut s = 1.0f32;
for &ch in &rgb {
let d = ch - y;
if ch > max && d > 1e-6 {
s = s.min(((max - y) / d).max(0.0));
}
if ch < 0.0 && d < -1e-6 {
s = s.min((y / -d).max(0.0));
}
}
if axis == 3 && s < 1.0 {
// smart limit: soft chroma compression rather than a hard cut
s = knee(s, 1.0, comp.max(0.03)).min(1.0);
}
cb *= s;
cr *= s;
}
to_rgb(y, cb, cr).map(|q| q.clamp(0.0, max))
}
/// Vignette: darkens (negative Amount, towards Color) or lightens the frame edges.
pub fn vignette(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let amt = fv(e, "amount", cx) / 100.0;
if amt.abs() < 1e-5 {
return;
}
let mid = fv(e, "midpoint", cx) / 100.0;
let round = fv(e, "roundness", cx) / 100.0;
let feather = (fv(e, "feather", cx) / 100.0).max(0.01);
let col = cv(e, "color", cx);
let target = if amt < 0.0 { [col[0], col[1], col[2]] } else { [1.0; 3] };
let (w, h) = (img.w as f32, img.h as f32);
let aspect = w / h;
img.map_rgb(|c, x, y| {
let mut nx = (x as f32 + 0.5) / w * 2.0 - 1.0;
let ny = (y as f32 + 0.5) / h * 2.0 - 1.0;
// roundness 100 = circle; 0 = an ellipse following the frame; −100 = squarer
if round > 0.0 {
nx *= 1.0 + (aspect - 1.0) * round;
}
let p = if round < 0.0 { 2.0 + (-round) * 6.0 } else { 2.0 };
// 0 at the centre, 1 at the corners
let d = (nx.abs().powf(p) + ny.abs().powf(p)).powf(1.0 / p) / 2f32.powf(1.0 / p);
let edge = smoothstep(mid - feather * 0.5, mid + feather * 0.5, d);
dec(lerp3(enc(c), target, edge * amt.abs()))
});
}
/// Channel Mix: each output channel = Σ input × weight / 100 + constant / 100 (display-encoded).
pub fn channel_mix(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let g = |k: &str| fv(e, k, cx) / 100.0;
let mut m = [[g("rr"), g("rg"), g("rb"), g("rc")], [g("gr"), g("gg"), g("gb"), g("gc")], [g("br"), g("bg"), g("bb"), g("bc")]];
if bv(e, "monochrome") {
m = [m[0]; 3];
}
img.map_rgb(|c, _, _| {
let v = enc(c);
dec([0, 1, 2].map(|k| (m[k][0] * v[0] + m[k][1] * v[1] + m[k][2] * v[2] + m[k][3]).clamp(0.0, 1.0)))
});
}
/// Color Replace: pixels within Similarity of Target take the Replace colour (keeping their
/// lightness unless Solid Colors).
pub fn color_replace(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let sim = fv(e, "similarity", cx) / 100.0 * 1.2;
let solid = bv(e, "solid");
let t = cv(e, "target", cx);
let r = cv(e, "replace", cx);
let rh = rgb_to_hsl(r[0], r[1], r[2]);
img.map_rgb(|c, _, _| {
let v = enc(c);
let d = ((v[0] - t[0]).powi(2) + (v[1] - t[1]).powi(2) + (v[2] - t[2]).powi(2)).sqrt();
let k = 1.0 - smoothstep(sim * 0.85, sim.max(1e-4), d);
if k <= 0.0 {
return c;
}
let target = if solid {
[r[0], r[1], r[2]]
} else {
let l = rgb_to_hsl(v[0], v[1], v[2])[2];
hsl_to_rgb(rh[0], rh[1], l)
};
dec(lerp3(v, target, k))
});
}
/// Alpha Adjust: opacity, ignore / invert alpha, show the matte only.
pub fn alpha_adjust(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let op = fv(e, "opacity", cx) / 100.0;
let (ignore, invert, mask_only) = (bv(e, "ignore"), bv(e, "invert"), bv(e, "mask_only"));
img.px.par_chunks_mut(4).for_each(|p| {
let c = Image::unpremul([p[0], p[1], p[2], p[3]]);
let mut a = if ignore { 1.0 } else { p[3] };
if invert {
a = 1.0 - a;
}
a = (a * op).clamp(0.0, 1.0);
if mask_only {
let g = filmcraft_color::srgb_to_linear(a);
p.copy_from_slice(&[g, g, g, 1.0]);
} else {
p.copy_from_slice(&[c[0] * a, c[1] * a, c[2] * a, a]);
}
});
}
/// Logo Cutout: removes a flat white/black/custom background by un-multiplying it — the alpha of
/// a pixel is how far it is from the background towards the gamut edge, and the colour is
/// recovered as if it had been composited over that background.
pub fn logo_cutout(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let bg = match chv(e, "background") {
0 => [1.0; 3],
1 => [0.0; 3],
_ => {
let c = cv(e, "color", cx);
[c[0], c[1], c[2]]
}
};
let th = fv(e, "threshold", cx) / 100.0;
let soft = (fv(e, "softness", cx) / 100.0).max(1e-3);
let unmult = bv(e, "unmultiply");
let invert = bv(e, "invert");
img.px.par_chunks_mut(4).for_each(|p| {
if p[3] <= 1e-6 {
return;
}
let v = enc(Image::unpremul([p[0], p[1], p[2], p[3]]));
let mut a0 = 0.0f32;
for k in 0..3 {
let d = v[k] - bg[k];
let room = if d >= 0.0 { 1.0 - bg[k] } else { bg[k] };
if room > 1e-6 {
a0 = a0.max(d.abs() / room);
}
}
let a0 = a0.clamp(0.0, 1.0);
let mut a = ((a0 - th) / ((1.0 - th).max(1e-3) * soft)).clamp(0.0, 1.0);
if invert {
a = 1.0 - a;
}
let col = if unmult && a0 > 1e-4 && !invert { [0, 1, 2].map(|k| (bg[k] + (v[k] - bg[k]) / a0).clamp(0.0, 1.0)) } else { v };
let l = dec(col);
let na = a * p[3];
p.copy_from_slice(&[l[0] * na, l[1] * na, l[2] * na, na]);
});
}
/// Ultra Key: a colour-difference keyer working in Y′CbCr against the key colour's chroma, with
/// Premiere's matte generation, cleanup, spill suppression and colour correction stages.
pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let kc = cv(e, "key_color", cx);
let k = rgb_to_ycbcr(kc[0], kc[1], kc[2], Matrix::Bt709);
let kmag2 = (k[1] * k[1] + k[2] * k[2]).max(1e-6);
let setting = chv(e, "setting");
let (g_mul, ped_mul) = match setting {
1 => (0.85, 0.7),
2 => (1.2, 1.5),
_ => (1.0, 1.0),
};
let transparency = fv(e, "transparency", cx) / 100.0;
let highlight = fv(e, "highlight", cx) / 100.0;
let shadow = fv(e, "shadow", cx) / 100.0;
let tol = 0.08 + fv(e, "tolerance", cx) / 100.0 * 0.5;
let pedestal = (fv(e, "pedestal", cx) / 100.0 * 0.3 * ped_mul).min(0.9);
let gain = (1.0 + 2.0 * transparency) * g_mul;
let output = chv(e, "output");
let dom = if kc[1] >= kc[0] && kc[1] >= kc[2] {
1
} else if kc[2] >= kc[0] {
2
} else {
0
};
// matte
let n = img.w * img.h;
let mut alpha = vec![1.0f32; n];
alpha.par_iter_mut().zip(img.px.par_chunks(4)).for_each(|(a, p)| {
if p[3] <= 1e-6 {
*a = 0.0;
return;
}
let v = enc(Image::unpremul([p[0], p[1], p[2], p[3]]));
let y = rgb_to_ycbcr(v[0], v[1], v[2], Matrix::Bt709);
let proj = (y[1] * k[1] + y[2] * k[2]) / kmag2;
let perp = ((y[1] - proj * k[1]).powi(2) + (y[2] - proj * k[2]).powi(2)).sqrt() / kmag2.sqrt();
let keyness = proj.clamp(0.0, 1.5) * (1.0 - smoothstep(0.0, tol, perp));
let mut m = (1.0 - keyness * gain).clamp(0.0, 1.0);
m += (y[0] - k[0]).max(0.0) * highlight * 2.0 * keyness.min(1.0);
m += (k[0] - y[0]).max(0.0) * shadow * 2.0 * keyness.min(1.0);
*a = ((m.clamp(0.0, 1.0) - pedestal) / (1.0 - pedestal)).clamp(0.0, 1.0);
});
// cleanup
let choke = fv(e, "choke", cx) / 100.0;
let soften = fv(e, "soften", cx) / 100.0;
let contrast = fv(e, "contrast", cx) / 100.0;
let mid = fv(e, "mid_point", cx) / 100.0;
if choke > 0.0 {
let r = (choke * 4.0 * cx.px_scale).max(0.5);
let b = blur_plane(&alpha, img.w, img.h, r);
let c = choke * 0.5;
alpha.par_iter_mut().zip(b.par_iter()).for_each(|(a, &bb)| *a = a.min(((bb - c) / (1.0 - c)).clamp(0.0, 1.0)));
}
if soften > 0.0 {
alpha = blur_plane(&alpha, img.w, img.h, soften * 10.0 * cx.px_scale);
}
if contrast > 0.0 {
let g = 1.0 + contrast * 4.0;
alpha.par_iter_mut().for_each(|a| *a = ((*a - mid) * g + mid).clamp(0.0, 1.0));
}
// spill suppression + colour correction on the foreground
let spill = fv(e, "spill", cx) / 100.0;
let desat = fv(e, "desaturate", cx) / 100.0;
let range = fv(e, "range", cx) / 100.0;
let sluma = fv(e, "spill_luma", cx) / 100.0;
let (cc_s, cc_h, cc_l) = (fv(e, "cc_saturation", cx) / 100.0, fv(e, "cc_hue", cx) / 360.0, fv(e, "cc_luminance", cx) / 100.0);
let cc = (cc_s - 1.0).abs() > 1e-4 || cc_h.abs() > 1e-6 || (cc_l - 1.0).abs() > 1e-4;
img.px.par_chunks_mut(4).zip(alpha.par_iter()).for_each(|(p, &a)| {
let src_a = p[3];
let mut v = enc(Image::unpremul([p[0], p[1], p[2], p[3]]));
let (o1, o2) = (v[(dom + 1) % 3], v[(dom + 2) % 3]);
let limit = o1.max(o2) * (1.0 - range) + (o1 + o2) * 0.5 * range;
let excess = (v[dom] - limit).max(0.0);
if excess > 0.0 && spill > 0.0 {
let l0 = luma(v);
v[dom] -= excess * spill;
let l1 = luma(v);
let lm = luma(v);
v = v.map(|q| lm + (q - lm) * (1.0 - desat * (excess * 4.0).min(1.0)));
let comp = (l0 - l1) * sluma;
v = v.map(|q| q + comp);
}
if cc {
let mut h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0));
h[0] = (h[0] + cc_h).rem_euclid(1.0);
h[1] = (h[1] * cc_s).clamp(0.0, 1.0);
h[2] = (h[2] * cc_l).clamp(0.0, 1.0);
v = hsl_to_rgb(h[0], h[1], h[2]);
}
let l = dec(v);
match output {
1 => {
let g = filmcraft_color::srgb_to_linear(a);
p.copy_from_slice(&[g * src_a, g * src_a, g * src_a, src_a]);
}
2 => p.copy_from_slice(&[l[0] * src_a, l[1] * src_a, l[2] * src_a, src_a]),
_ => {
let na = a * src_a;
p.copy_from_slice(&[l[0] * na, l[1] * na, l[2] * na, na]);
}
}
});
}
/// Track Matte Key: multiplies the layer by another video track's alpha or luma (sampled where
/// the layer lands in the frame).
pub fn track_matte(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let idx = chv(e, "matte") as usize;
if idx == 0 {
return;
}
let Some(env) = cx.env else { return };
let Some(matte) = env.track(idx - 1) else { return };
apply_matte(img, &matte, &env.layer_to_output(), chv(e, "composite") == 1, bv(e, "reverse"));
}
/// Multiply `img` (layer pixels) by `matte` (output pixels) mapped through `m`.
pub(crate) fn apply_matte(img: &mut Image, matte: &Image, m: &Affine, use_luma: bool, reverse: bool) {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let o = m.apply(Vec2::new(x as f64 + 0.5, y as f64 + 0.5));
let s = matte.sample_bilinear(o.x as f32, o.y as f32);
let mut v = if use_luma { filmcraft_color::linear_to_srgb(luma([s[0], s[1], s[2]]).clamp(0.0, 1.0)) } else { s[3] };
if reverse {
v = 1.0 - v;
}
for c in &mut row[x * 4..x * 4 + 4] {
*c *= v.clamp(0.0, 1.0);
}
}
});
}
/// Cineon Converter (Kodak Cineon printing-density model, film gamma 0.6, 0.002 density / code).
pub fn cineon(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let mode = chv(e, "conversion");
let black = fv(e, "black10", cx);
let white = fv(e, "white10", cx).max(black + 1.0);
let ib = fv(e, "black_internal", cx) / 255.0;
let iw = fv(e, "white_internal", cx) / 255.0;
let gamma = fv(e, "gamma", cx).max(0.1);
let roll = fv(e, "rolloff", cx) / 255.0;
img.map_rgb(|c, _, _| dec(enc(c).map(|v| cineon_value(v, mode, black, white, ib, iw, gamma, roll))));
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cineon_value(v: f32, mode: u32, black: f32, white: f32, ib: f32, iw: f32, gamma: f32, roll: f32) -> f32 {
let k = 0.002 / 0.6;
let offset = 10f32.powf((black - white) * k);
match mode {
0 => {
let code = v * 1023.0;
let linv = ((10f32.powf((code - white) * k) - offset) / (1.0 - offset)).max(0.0);
let mut o = linv.powf(1.0 / gamma);
o = ib + o * (iw - ib);
let r0 = 1.0 - roll;
if roll > 1e-4 && o > r0 {
o = r0 + roll * (1.0 - (-(o - r0) / roll).exp());
}
o.clamp(0.0, 1.0)
}
1 => {
let d = ((v - ib) / (iw - ib).max(1e-4)).clamp(0.0, 1.0);
let linv = d.powf(gamma);
let code = white + (linv * (1.0 - offset) + offset).max(1e-6).log10() / k;
(code / 1023.0).clamp(0.0, 1.0)
}
_ => {
let code = v * 1023.0;
(((code - black) / (white - black)) * (685.0 - 95.0) + 95.0) / 1023.0
}
}
}
/// Noise (and Noise (Legacy)): per-frame random noise added in display space, with grain size.
pub fn noise(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let amt = fv(e, "amount", cx) / 100.0;
if amt <= 0.0 {
return;
}
let colored = bv(e, "color");
let clip = bv(e, "clip");
let grain = (fv(e, "grain_size", cx).max(1.0) * cx.px_scale).max(1.0);
let frame = (cx.seconds * 30.0) as u64;
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = &mut row[x * 4..x * 4 + 4];
let a = p[3];
if a <= 0.0 {
continue;
}
let (gx, gy) = ((x as f32 / grain) as usize, (y as f32 / grain) as usize);
let h = |z: u64| crate::effects::hash3(gx, gy, z) - 0.5;
let n0 = h(frame);
let ns = if colored { [n0, h(frame + 7777), h(frame + 99_991)] } else { [n0; 3] };
let c = enc([p[0] / a, p[1] / a, p[2] / a]);
let mut o = [c[0] + ns[0] * amt, c[1] + ns[1] * amt, c[2] + ns[2] * amt];
if clip {
o = o.map(|v| v.clamp(0.0, 1.0));
}
let o = dec(o);
p[0] = o[0] * a;
p[1] = o[1] * a;
p[2] = o[2] * a;
}
});
}
+520
View File
@@ -0,0 +1,520 @@
//! Distort and Transform effects (geometry).
use filmcraft_project::EffectInstance;
use rayon::prelude::*;
use super::*;
use crate::effects::{warp, warp_opt};
/// 3×3 projective matrix, row-major, mapping (x, y, 1).
pub(crate) type H3 = [f64; 9];
/// Heckbert's square-to-quad: maps the unit square's (0,0),(1,0),(1,1),(0,1) to `q`'s points.
pub(crate) fn square_to_quad(q: [Vec2; 4]) -> H3 {
let [p0, p1, p2, p3] = q;
let sx = p0.x - p1.x + p2.x - p3.x;
let sy = p0.y - p1.y + p2.y - p3.y;
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
return [p1.x - p0.x, p2.x - p1.x, p0.x, p1.y - p0.y, p2.y - p1.y, p0.y, 0.0, 0.0, 1.0];
}
let (dx1, dx2, dy1, dy2) = (p1.x - p2.x, p3.x - p2.x, p1.y - p2.y, p3.y - p2.y);
let det = dx1 * dy2 - dx2 * dy1;
let g = (sx * dy2 - dx2 * sy) / det;
let h = (dx1 * sy - sx * dy1) / det;
[p1.x - p0.x + g * p1.x, p3.x - p0.x + h * p3.x, p0.x, p1.y - p0.y + g * p1.y, p3.y - p0.y + h * p3.y, p0.y, g, h, 1.0]
}
pub(crate) fn h_inverse(m: &H3) -> Option<H3> {
let [a, b, c, d, e, f, g, h, i] = *m;
let co =
[e * i - f * h, -(d * i - f * g), d * h - e * g, -(b * i - c * h), a * i - c * g, -(a * h - b * g), b * f - c * e, -(a * f - c * d), a * e - b * d];
let det = a * co[0] + b * co[1] + c * co[2];
if det.abs() < 1e-18 {
return None;
}
Some([co[0] / det, co[3] / det, co[6] / det, co[1] / det, co[4] / det, co[7] / det, co[2] / det, co[5] / det, co[8] / det])
}
#[inline]
pub(crate) fn h_apply(m: &H3, x: f64, y: f64) -> Option<(f64, f64)> {
let w = m[6] * x + m[7] * y + m[8];
if w.abs() < 1e-12 {
return None;
}
Some(((m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w))
}
/// Corner Pin: the layer's corners moved to four points (a projective warp).
pub fn corner_pin(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (w, h) = (img.w as f64, img.h as f64);
let q = [pv(e, "upper_left", cx, img), pv(e, "upper_right", cx, img), pv(e, "lower_right", cx, img), pv(e, "lower_left", cx, img)];
let ident = [Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)];
if q.iter().zip(&ident).all(|(a, b)| (*a - *b).length() < 1e-6) {
return;
}
let Some(inv) = h_inverse(&square_to_quad(q)) else { return };
warp_opt(img, |x, y| {
let (u, v) = h_apply(&inv, x, y)?;
(u >= -1e-3 && v >= -1e-3 && u <= 1.0 + 1e-3 && v <= 1.0 + 1e-3).then_some((u * w, v * h))
});
}
/// Magnify (and Magnify (Legacy)): a circular or square loupe.
pub fn magnify(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = pv(e, "center", cx, img);
let m = (fv(e, "magnification", cx) / 100.0).max(0.01) as f64;
let r = fv(e, "size", cx) * cx.px_scale;
let fe = fv(e, "feather", cx) * cx.px_scale;
let op = fv(e, "opacity", cx) / 100.0;
let square = chv(e, "shape") == 1;
let mode = chv(e, "mode");
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (dx, dy) = (x as f64 + 0.5 - c.x, y as f64 + 0.5 - c.y);
let d = if square { dx.abs().max(dy.abs()) } else { dx.hypot(dy) } as f32;
let k = if fe > 0.0 { ((r - d) / fe).clamp(0.0, 1.0) } else { (r - d + 0.5).clamp(0.0, 1.0) } * op;
if k <= 0.0 {
continue;
}
let s = src.sample_bilinear((c.x + dx / m) as f32, (c.y + dy / m) as f32);
let p = &mut row[x * 4..x * 4 + 4];
let base = Image::unpremul([p[0], p[1], p[2], p[3]]);
let sc = Image::unpremul(s);
let mixed = blend_simple(mode, base, sc);
let a = s[3];
let out = [mixed[0] * a, mixed[1] * a, mixed[2] * a, a];
for i in 0..4 {
p[i] += (out[i] - p[i]) * k;
}
}
});
}
/// Spherize: wraps the layer around a sphere of the given radius (centre magnified 2×).
pub fn spherize(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let r = (fv(e, "radius", cx) * cx.px_scale) as f64;
if r < 0.5 {
return;
}
let c = pv(e, "center", cx, img);
warp(img, |x, y| {
let (dx, dy) = (x - c.x, y - c.y);
let d = dx.hypot(dy);
if d >= r || d < 1e-9 {
return (x, y);
}
let n = d / r;
let k = 1.0 - 0.5 * (1.0 - n * n).sqrt();
(c.x + dx * k, c.y + dy * k)
});
}
/// Turbulent Displace: fractal-noise displacement (turbulent, bulge, twist, directional), with
/// evolution, cycling and edge pinning.
pub fn turbulent_displace(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let kind = chv(e, "displacement");
let amount = fv(e, "amount", cx) * cx.px_scale;
if amount.abs() < 1e-4 {
return;
}
let size = (fv(e, "size", cx) * cx.px_scale).max(0.5);
let off = pv(e, "offset", cx, img);
let (ox, oy) = (((off.x - img.w as f64 / 2.0) / size as f64) as f32, ((off.y - img.h as f64 / 2.0) / size as f64) as f32);
let mut cplx = fv(e, "complexity", cx).clamp(1.0, 10.0);
if (3..=5).contains(&kind) {
cplx = (cplx * 0.5).max(1.0);
}
let seed = fv(e, "seed", cx).max(0.0) as u64;
let evo = fv(e, "evolution", cx) / 360.0;
let (ex, ey, ez) = if bv(e, "cycle") {
let revs = fv(e, "cycle_revs", cx).max(1.0);
let th = (evo.rem_euclid(revs) / revs) * std::f32::consts::TAU;
(th.cos() * 0.8, th.sin() * 0.8, 0.0)
} else {
(0.0, 0.0, evo)
};
let pin = chv(e, "pinning");
let (w, h) = (img.w as f32, img.h as f32);
let n = |x: f32, y: f32, ch: f32| fbm(x + ex + ch * 31.7, y + ey, ez + ch * 3.1, cplx, seed);
warp(img, |x, y| {
let (u, v) = (x as f32 / size + ox, y as f32 / size + oy);
let (dx, dy) = match kind {
0 | 3 => (n(u, v, 0.0), n(u, v, 1.0)),
1 | 4 | 2 | 5 => {
let eps = 0.05;
let gx = (n(u + eps, v, 0.0) - n(u - eps, v, 0.0)) / (2.0 * eps);
let gy = (n(u, v + eps, 0.0) - n(u, v - eps, 0.0)) / (2.0 * eps);
if kind == 1 || kind == 4 { (gx * 0.3, gy * 0.3) } else { (-gy * 0.3, gx * 0.3) }
}
6 => (0.0, n(u, v, 0.0)),
7 => (n(u, v, 0.0), 0.0),
_ => {
let q = n(u, v, 0.0);
(q, q)
}
};
let (px, py) = (x as f32, y as f32);
let edge = |d: f32, len: f32| smoothstep(1.0, (len * 0.1).max(2.0), d);
let (kx, ky) = match pin {
1 => {
let k = edge(px.min(w - px), w).min(edge(py.min(h - py), h));
(k, k)
}
2 => {
let k = edge(px.min(w - px), w);
(k, k)
}
3 => {
let k = edge(py.min(h - py), h);
(k, k)
}
_ => (1.0, 1.0),
};
(x - (dx * amount * kx) as f64, y - (dy * amount * ky) as f64)
});
}
/// 3D Rotate: rotates the layer about its centre in 3-D (X, then Y, then Z) with perspective.
pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (ax, ay, az) = ((fv(e, "rot_x", cx) as f64).to_radians(), (fv(e, "rot_y", cx) as f64).to_radians(), (fv(e, "rot_z", cx) as f64).to_radians());
let z = fv(e, "z", cx) as f64 * cx.px_scale as f64;
if ax.abs() < 1e-9 && ay.abs() < 1e-9 && az.abs() < 1e-9 && z.abs() < 1e-9 {
return;
}
let persp = fv(e, "perspective", cx).clamp(0.0, 100.0) as f64;
let (w, h) = (img.w as f64, img.h as f64);
let f = if persp <= 0.0 { 1e7 } else { w.max(h) * (0.6 + (100.0 - persp) / 100.0 * 4.0) };
let hide = bv(e, "hide_back");
let (sx, cxr) = ax.sin_cos();
let (sy, cy) = ay.sin_cos();
let (sz, cz) = az.sin_cos();
// R = Rz · Ry · Rx (screen y down; positive Z is clockwise like Rotation)
let rx = [[1.0, 0.0, 0.0], [0.0, cxr, -sx], [0.0, sx, cxr]];
let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
let mul = |a: [[f64; 3]; 3], b: [[f64; 3]; 3]| {
let mut o = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
o
};
let r = mul(rz, mul(ry, rx));
let u = [r[0][0], r[1][0], r[2][0]];
let v = [r[0][1], r[1][1], r[2][1]];
let n = [r[0][2], r[1][2], r[2][2]];
let back = n[2] < 0.0;
if hide && back {
img.px.fill(0.0);
return;
}
let c = [0.0, 0.0, f + z];
let nc = n[0] * c[0] + n[1] * c[1] + n[2] * c[2];
warp_opt(img, |x, y| {
let d = [x - w / 2.0, y - h / 2.0, f];
let den = n[0] * d[0] + n[1] * d[1] + n[2] * d[2];
if den.abs() < 1e-12 {
return None;
}
let t = nc / den;
if t <= 0.0 {
return None;
}
let l = [d[0] * t - c[0], d[1] * t - c[1], d[2] * t - c[2]];
let su = l[0] * u[0] + l[1] * u[1] + l[2] * u[2];
let sv = l[0] * v[0] + l[1] * v[1] + l[2] * v[2];
Some((su + w / 2.0, sv + h / 2.0))
});
}
/// Grow / Shrink: scale animated across the clip from Scale From to Scale To.
pub fn grow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let p = ease(chv(e, "easing"), clip_progress(cx));
let (a, b) = (fv(e, "from", cx) as f64, fv(e, "to", cx) as f64);
let s = (a + (b - a) * p) / 100.0;
let c = pv(e, "center", cx, img);
place(img, c, Vec2::ZERO, s.max(1e-4), 0.0);
}
/// Accumulate `n` renders of `f(img, k)` for k in −0.5..0.5 (simple motion blur).
fn motion_blurred(img: &mut Image, n: usize, f: impl Fn(&mut Image, f64) + Sync) {
if n <= 1 {
f(img, 0.0);
return;
}
let src = img.clone();
let parts: Vec<Image> = (0..n)
.into_par_iter()
.map(|i| {
let mut c = src.clone();
f(&mut c, i as f64 / (n - 1) as f64 - 0.5);
c
})
.collect();
let inv = 1.0 / n as f32;
img.px.par_iter_mut().enumerate().for_each(|(i, v)| *v = parts.iter().map(|p| p.px[i]).sum::<f32>() * inv);
}
/// Move: the layer slides from Offset From to Offset To across the clip.
pub fn move_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let p = clip_progress(cx);
let kind = chv(e, "easing");
let (a, b) = (offv(e, "from", cx), offv(e, "to", cx));
let blur = fv(e, "motion_blur", cx) as f64 / 100.0;
let span = cx.env.map_or(0.0, |env| 1.0 / (env.frame_rate() * env.clip_seconds()).max(1.0));
let c = Vec2::new(img.w as f64 / 2.0, img.h as f64 / 2.0);
let n = if blur > 0.0 && span > 0.0 { 5 } else { 1 };
motion_blurred(img, n, |im, k| {
let q = ease(kind, p + k * blur * span);
place(im, c, a + (b - a) * q, 1.0, 0.0);
});
}
/// Spin: rotates by Rotation Amount across the clip.
pub fn spin(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let p = ease(chv(e, "easing"), clip_progress(cx));
let ang = fv(e, "amount", cx) as f64 * p;
let s = fv(e, "scale", cx) as f64 / 100.0;
let c = pv(e, "center", cx, img);
place(img, c, Vec2::ZERO, s.max(1e-4), ang);
}
/// Wiggle: smooth random position / rotation / scale jitter.
pub fn wiggle(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let t = cx.seconds * fv(e, "frequency", cx) as f64;
let seed = fv(e, "seed", cx).max(0.0) as u64;
let amt = (fv(e, "amount", cx) * cx.px_scale) as f64;
let dims = chv(e, "dimensions");
let ox = if dims == 2 { 0.0 } else { noise1(t, 0, seed) as f64 * amt * 1.6 };
let oy = if dims == 1 { 0.0 } else { noise1(t, 1, seed) as f64 * amt * 1.6 };
let rot = noise1(t, 2, seed) as f64 * fv(e, "rotation", cx) as f64 * 1.6;
let sc = 1.0 + noise1(t, 3, seed) as f64 * fv(e, "scale", cx) as f64 / 100.0 * 1.6;
let c = Vec2::new(img.w as f64 / 2.0, img.h as f64 / 2.0);
place(img, c, Vec2::new(ox, oy), sc.max(1e-3), rot);
}
/// Camera Shake: layered smooth noise on position, rotation and zoom, with optional motion blur.
pub fn camera_shake(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let freq = fv(e, "frequency", cx) as f64;
let seed = fv(e, "seed", cx).max(0.0) as u64;
let amt = (fv(e, "amount", cx) * cx.px_scale) as f64;
let rot = fv(e, "rotation", cx) as f64;
let zoom = fv(e, "zoom", cx) as f64 / 100.0;
let octaves = fv(e, "complexity", cx).clamp(1.0, 6.0) as usize;
let blur = fv(e, "motion_blur", cx) as f64 / 100.0;
let fps = cx.env.map_or(30.0, |env| env.frame_rate());
let shake = |t: f64, ch: u64| -> f64 {
let (mut s, mut a, mut f, mut norm) = (0.0, 1.0, 1.0, 0.0);
for o in 0..octaves {
s += noise1(t * freq * f, ch * 10 + o as u64, seed) as f64 * a;
norm += a;
a *= 0.5;
f *= 2.0;
}
s / norm * 1.6
};
let c = Vec2::new(img.w as f64 / 2.0, img.h as f64 / 2.0);
let t0 = cx.seconds;
let n = if blur > 0.0 { 5 } else { 1 };
motion_blurred(img, n, |im, k| {
let t = t0 + k * blur / fps;
let off = Vec2::new(shake(t, 0) * amt, shake(t, 1) * amt);
// zoom in a little with the shake so edges stay covered
let s = 1.0 + zoom * (0.5 + 0.5 * shake(t, 3));
place(im, c, off, s, shake(t, 2) * rot);
});
}
/// Spacer: insets the layer inside margins (scaled to fit), with rounded corners and an optional
/// background fill.
pub fn spacer(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let k = cx.px_scale as f64;
let l = fv(e, "left", cx) as f64 * k;
let (t, r, b) = if bv(e, "uniform") { (l, l, l) } else { (fv(e, "top", cx) as f64 * k, fv(e, "right", cx) as f64 * k, fv(e, "bottom", cx) as f64 * k) };
let (w, h) = (img.w as f64, img.h as f64);
let (bw, bh) = ((w - l - r).max(1.0), (h - t - b).max(1.0));
let s = (bw / w).min(bh / h);
let (dw, dh) = (w * s, h * s);
let (x0, y0) = (l + (bw - dw) / 2.0, t + (bh - dh) / 2.0);
let m = Affine::translate(x0, y0).then_apply(&Affine::scale(s, s));
let mut out = img.transformed(img.w, img.h, &m);
let rad = (fv(e, "radius", cx) as f64 * k).min(dw.min(dh) / 2.0);
if rad > 0.0 {
round_rect_alpha(&mut out, x0, y0, x0 + dw, y0 + dh, rad as f32, 0.0);
}
if bv(e, "fill") {
let c = lin(cv(e, "color", cx));
out.px.par_chunks_mut(4).for_each(|p| {
let k = 1.0 - p[3];
p[0] += c[0] * k;
p[1] += c[1] * k;
p[2] += c[2] * k;
p[3] = 1.0;
});
}
*img = out;
}
/// Signed distance from (px, py) to a rounded rectangle (negative inside).
#[inline]
pub(crate) fn round_rect_sdf(px: f32, py: f32, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> f32 {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (hx, hy) = ((x1 - x0) / 2.0 - r, (y1 - y0) / 2.0 - r);
let (qx, qy) = ((px - cx).abs() - hx, (py - cy).abs() - hy);
let outside = qx.max(0.0).hypot(qy.max(0.0));
outside + qx.max(qy).min(0.0) - r
}
/// Multiply alpha by the coverage of a rounded rectangle (feathered inward by `feather`).
fn round_rect_alpha(img: &mut Image, x0: f64, y0: f64, x1: f64, y1: f64, r: f32, feather: f32) {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let d = round_rect_sdf(x as f32 + 0.5, y as f32 + 0.5, x0 as f32, y0 as f32, x1 as f32, y1 as f32, r);
let a = if feather > 0.0 { (-d / feather).clamp(0.0, 1.0) } else { (0.5 - d).clamp(0.0, 1.0) };
if a < 1.0 {
for v in &mut row[x * 4..x * 4 + 4] {
*v *= a;
}
}
}
});
}
/// Rounded Crop: crop edges with rounded corners, feather and an optional border.
pub fn rounded_crop(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (w, h) = (img.w as f32, img.h as f32);
let (l, t, r, b) = (fv(e, "left", cx) / 100.0 * w, fv(e, "top", cx) / 100.0 * h, w - fv(e, "right", cx) / 100.0 * w, h - fv(e, "bottom", cx) / 100.0 * h);
let rad = (fv(e, "radius", cx) * cx.px_scale).min(((r - l).min(b - t) / 2.0).max(0.0));
let fe = fv(e, "feather", cx) * cx.px_scale;
let border = fv(e, "border", cx) * cx.px_scale;
let bc = lin(cv(e, "border_color", cx));
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let d = round_rect_sdf(x as f32 + 0.5, y as f32 + 0.5, l, t, r, b, rad);
let p = &mut row[x * 4..x * 4 + 4];
if border > 0.0 {
// coverage of the band −border < d < 0 just inside the edge
let k = (0.5 - d).clamp(0.0, 1.0) - (0.5 - d - border).clamp(0.0, 1.0);
if k > 0.0 {
over(p, [bc[0] * k, bc[1] * k, bc[2] * k, k]);
}
}
let a = if fe > 0.0 { (-d / fe).clamp(0.0, 1.0) } else { (0.5 - d).clamp(0.0, 1.0) };
if a < 1.0 {
for v in p.iter_mut() {
*v *= a;
}
}
}
});
}
/// Clone: the layer repeated in a grid of tiles (optionally mirrored alternately).
pub fn clone_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let cols = fv(e, "columns", cx).clamp(1.0, 16.0) as usize;
let rows = fv(e, "rows", cx).clamp(1.0, 16.0) as usize;
if cols == 1 && rows == 1 {
return;
}
let gap = (fv(e, "gap", cx) * cx.px_scale) as f64;
let mirror = bv(e, "mirror");
let (w, h) = (img.w as f64, img.h as f64);
let tw = ((w - gap * (cols - 1) as f64) / cols as f64).max(1.0);
let th = ((h - gap * (rows - 1) as f64) / rows as f64).max(1.0);
let s = (tw / w).min(th / h);
let src = img.clone();
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let (ci, ri) = ((px / (tw + gap)).floor() as usize, (py / (th + gap)).floor() as usize);
let (ci, ri) = (ci.min(cols - 1), ri.min(rows - 1));
let (ox, oy) = (ci as f64 * (tw + gap) + (tw - w * s) / 2.0, ri as f64 * (th + gap) + (th - h * s) / 2.0);
let mut u = (px - ox) / s;
let v = (py - oy) / s;
if mirror && (ci + ri) % 2 == 1 {
u = w - u;
}
let p = if u < 0.0 || v < 0.0 || u > w || v > h { [0.0; 4] } else { src.sample_bilinear(u as f32, v as f32) };
row[x * 4..x * 4 + 4].copy_from_slice(&p);
}
});
}
/// Auto Align: moves the layer's visible content (alpha bounding box) to the chosen frame edge
/// or centre.
pub fn auto_align(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (hz, vt) = (chv(e, "horizontal"), chv(e, "vertical"));
if hz == 0 && vt == 0 {
return;
}
let w = img.w;
let bounds = img
.px
.par_chunks(w * 4)
.enumerate()
.filter_map(|(y, row)| {
let xs: Vec<usize> = (0..w).filter(|&x| row[x * 4 + 3] > 0.01).collect();
Some((xs.first().copied()?, xs.last().copied()?, y))
})
.fold(|| (usize::MAX, 0usize, usize::MAX, 0usize), |a, (x0, x1, y)| (a.0.min(x0), a.1.max(x1), a.2.min(y), a.3.max(y)))
.reduce(|| (usize::MAX, 0usize, usize::MAX, 0usize), |a, b| (a.0.min(b.0), a.1.max(b.1), a.2.min(b.2), a.3.max(b.3)));
if bounds.0 == usize::MAX {
return;
}
let (x0, x1, y0, y1) = (bounds.0 as f64, bounds.1 as f64 + 1.0, bounds.2 as f64, bounds.3 as f64 + 1.0);
let (mx, my) = ((fv(e, "margin_x", cx) * cx.px_scale) as f64, (fv(e, "margin_y", cx) * cx.px_scale) as f64);
let (fw, fh) = (img.w as f64, img.h as f64);
let dx = match hz {
1 => mx - x0,
2 => (fw - (x1 - x0)) / 2.0 - x0,
3 => fw - mx - x1,
_ => 0.0,
};
let dy = match vt {
1 => my - y0,
2 => (fh - (y1 - y0)) / 2.0 - y0,
3 => fh - my - y1,
_ => 0.0,
};
affine_warp(img, &Affine::translate(dx.round(), dy.round()));
}
/// Mosaic (and Mosaic (Legacy)): solid blocks; Softness blends towards a smooth interpolation of
/// the block colours.
pub fn mosaic(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let bx = fv(e, "horizontal", cx).max(1.0) as usize;
let by = fv(e, "vertical", cx).max(1.0) as usize;
let soft = (fv(e, "softness", cx) / 100.0).clamp(0.0, 1.0);
let (w, h) = (img.w, img.h);
let bw = (w as f32 / bx as f32).max(1.0);
let bh = (h as f32 / by as f32).max(1.0);
let (nx, ny) = ((w as f32 / bw).ceil() as usize, (h as f32 / bh).ceil() as usize);
let mut small = Image::new(nx.max(1), ny.max(1));
for j in 0..small.h {
for i in 0..small.w {
let p = img.sample_bilinear_clamped((i as f32 + 0.5) * bw, (j as f32 + 0.5) * bh);
let k = (j * small.w + i) * 4;
small.px[k..k + 4].copy_from_slice(&p);
}
}
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (u, v) = ((x as f32 + 0.5) / bw, (y as f32 + 0.5) / bh);
let sharp = small.get((u as usize).min(small.w - 1), (v as usize).min(small.h - 1));
let p = if soft > 0.0 {
let sm = small.sample_bilinear_clamped(u, v);
[0, 1, 2, 3].map(|k| sharp[k] + (sm[k] - sharp[k]) * soft)
} else {
sharp
};
row[x * 4..x * 4 + 4].copy_from_slice(&p);
}
});
}
+436
View File
@@ -0,0 +1,436 @@
//! Immersive Video (VR) effects on equirectangular frames.
//!
//! Every effect treats each view (the whole frame, or the two halves of an over/under or
//! side-by-side stereoscopic layout) as a full sphere: x is longitude −180°…180° (wrapping), y is
//! latitude 90°…−90°. Filters are seam-free across the ±180° edge and widen horizontally towards
//! the poles (an equirectangular row at latitude φ covers cos φ of the equator's length).
use std::f64::consts::{PI, TAU};
use filmcraft_project::EffectInstance;
use rayon::prelude::*;
use super::*;
type V3 = [f64; 3];
#[inline]
fn dir(lon: f64, lat: f64) -> V3 {
[lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos()]
}
#[inline]
fn lonlat(d: V3) -> (f64, f64) {
(d[0].atan2(d[2]), d[1].clamp(-1.0, 1.0).asin())
}
#[inline]
fn dot(a: V3, b: V3) -> f64 {
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]]
}
type M3 = [[f64; 3]; 3];
fn mmul(a: M3, b: M3) -> M3 {
let mut o = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
o
}
fn mvec(m: &M3, v: V3) -> V3 {
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
}
fn transpose(m: M3) -> M3 {
[[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]]
}
/// Rotation from tilt (about X), pan (about Y) and roll (about Z), degrees: R = Ry · Rx · Rz.
fn rotation(tilt: f64, pan: f64, roll: f64) -> M3 {
let (sx, cx) = tilt.to_radians().sin_cos();
let (sy, cy) = pan.to_radians().sin_cos();
let (sz, cz) = roll.to_radians().sin_cos();
let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
mmul(ry, mmul(rx, rz))
}
#[inline]
fn px_to_lonlat(x: f64, y: f64, w: usize, h: usize) -> (f64, f64) {
((x / w as f64 - 0.5) * TAU, (0.5 - y / h as f64) * PI)
}
/// Bilinear sample with horizontal wrap (vertical clamp), pixel centres at +0.5.
fn sample_wrap(img: &Image, x: f64, y: f64) -> [f32; 4] {
let (w, h) = (img.w as isize, img.h as isize);
let fx = x - 0.5;
let fy = (y - 0.5).clamp(0.0, (h - 1) as f64);
let (x0, y0) = (fx.floor(), fy.floor());
let (tx, ty) = ((fx - x0) as f32, (fy - y0) as f32);
let (x0, y0) = (x0 as isize, y0 as isize);
let g = |xx: isize, yy: isize| img.get(xx.rem_euclid(w) as usize, yy.clamp(0, h - 1) as usize);
let (a, b, c, d) = (g(x0, y0), g(x0 + 1, y0), g(x0, y0 + 1), g(x0 + 1, y0 + 1));
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
}
fn sample_dir(img: &Image, d: V3) -> [f32; 4] {
let (lon, lat) = lonlat(d);
sample_wrap(img, (lon / TAU + 0.5) * img.w as f64, (0.5 - lat / PI) * img.h as f64)
}
/// Run `f` on each view of the frame layout.
fn per_view(img: &mut Image, layout: u32, f: impl Fn(&mut Image)) {
let (w, h) = (img.w, img.h);
let views: Vec<(usize, usize, usize, usize)> = match layout {
1 if h >= 2 => vec![(0, 0, w, h / 2), (0, h / 2, w, h - h / 2)],
2 if w >= 2 => vec![(0, 0, w / 2, h), (w / 2, 0, w - w / 2, h)],
_ => vec![(0, 0, w, h)],
};
if views.len() == 1 {
f(img);
return;
}
for (x0, y0, vw, vh) in views {
let mut v = Image::new(vw, vh);
for y in 0..vh {
let s = ((y0 + y) * w + x0) * 4;
v.px[y * vw * 4..(y + 1) * vw * 4].copy_from_slice(&img.px[s..s + vw * 4]);
}
f(&mut v);
for y in 0..vh {
let s = ((y0 + y) * w + x0) * 4;
img.px[s..s + vw * 4].copy_from_slice(&v.px[y * vw * 4..(y + 1) * vw * 4]);
}
}
}
/// Three-pass box approximation of a Gaussian along each row with wrap-around; the sigma of a
/// row is `sigma / cos(latitude)` (capped at a quarter turn).
fn blur_rows_wrap(img: &mut Image, sigma: f32) {
let (w, h) = (img.w, img.h);
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
let lat = (0.5 - (y as f64 + 0.5) / h as f64) * PI;
let s = (sigma as f64 / lat.cos().max(1e-3)).min(w as f64 / 4.0) as f32;
if s <= 0.3 {
return;
}
let r = ((((12.0 * s * s / 3.0 + 1.0).sqrt() - 1.0) / 2.0).round().max(1.0) as isize).min((w as isize - 1) / 2).max(0);
if r == 0 {
return;
}
let wi = w as isize;
let mut tmp = row.to_vec();
for _ in 0..3 {
let inv = 1.0 / (2 * r + 1) as f32;
// sliding window sum with wrap-around
let mut acc = [0f32; 4];
for j in -r..=r {
let i = j.rem_euclid(wi) as usize;
for k in 0..4 {
acc[k] += row[i * 4 + k];
}
}
for x in 0..wi {
for k in 0..4 {
tmp[x as usize * 4 + k] = acc[k] * inv;
}
let (out_i, in_i) = ((x - r).rem_euclid(wi) as usize, (x + r + 1).rem_euclid(wi) as usize);
for k in 0..4 {
acc[k] += row[in_i * 4 + k] - row[out_i * 4 + k];
}
}
row.copy_from_slice(&tmp);
}
});
}
fn vr_blur_image(img: &mut Image, sigma: f32) {
if sigma <= 0.3 {
return;
}
blur_rows_wrap(img, sigma);
crate::effects::gaussian(img, 0.0, sigma, true);
}
pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let layout = chv(e, "frame_layout");
match e.effect.as_str() {
"vr_blur" => {
let s = fv(e, "blurriness", cx) * cx.px_scale * 0.5;
per_view(img, layout, |v| vr_blur_image(v, s));
}
"vr_sharpen" => {
let amt = fv(e, "amount", cx) / 100.0;
if amt <= 0.0 {
return;
}
let s = cx.px_scale.max(0.35) * 1.2;
per_view(img, layout, |v| {
let mut b = v.clone();
vr_blur_image(&mut b, s);
v.px.par_chunks_mut(4).zip(b.px.par_chunks(4)).for_each(|(p, q)| {
for k in 0..3 {
p[k] = (p[k] + (p[k] - q[k]) * amt).max(0.0);
}
});
});
}
"vr_denoise" => {
let level = fv(e, "amount", cx) / 100.0;
if level <= 0.0 {
return;
}
let slow = chv(e, "noise_type") == 1;
let show = bv(e, "show_noise");
let s = (level * if slow { 3.0 } else { 1.8 }) * cx.px_scale.max(0.35);
let thr = 0.02 + level * 0.12;
per_view(img, layout, |v| {
let mut b = v.clone();
vr_blur_image(&mut b, s);
v.px.par_chunks_mut(4).zip(b.px.par_chunks(4)).for_each(|(p, q)| {
let d = (0..3).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max);
let k = (-(d * d) / (thr * thr)).exp();
let o: Vec<f32> = (0..4).map(|c| p[c] + (q[c] - p[c]) * k).collect();
if show {
for c in 0..3 {
p[c] = ((p[c] - o[c]) * 4.0 + 0.18).max(0.0) * p[3];
}
} else {
p.copy_from_slice(&o);
}
});
});
}
"vr_glow" => {
let th = fv(e, "threshold", cx) / 100.0;
let r = fv(e, "radius", cx) * cx.px_scale * 0.5;
let br = fv(e, "brightness", cx);
let sat = fv(e, "saturation", cx);
let tint = bv(e, "use_tint").then(|| lin(cv(e, "tint", cx)));
per_view(img, layout, |v| {
let mut hi = highlights(v, th, 0.1);
vr_blur_image(&mut hi, r);
v.px.par_chunks_mut(4).zip(hi.px.par_chunks(4)).for_each(|(p, q)| {
let l = luma([q[0], q[1], q[2]]);
let mut g = [0, 1, 2].map(|k| (l + (q[k] - l) * sat).max(0.0));
if let Some(t) = tint {
g = t.map(|c| c * l);
}
add_light(p, g.map(|c| c * br));
});
});
}
"vr_chromatic_aberrations" => {
let ab = [fv(e, "red", cx), fv(e, "green", cx), fv(e, "blue", cx)];
if ab.iter().all(|a| a.abs() < 1e-4) {
return;
}
let center = dir((fv(e, "center_x", cx) as f64).to_radians(), (fv(e, "center_y", cx) as f64).to_radians());
let p = 0.5 + fv(e, "falloff", cx) as f64 / 50.0;
let inverse = bv(e, "inverse");
per_view(img, layout, |v| {
let src = v.clone();
let (w, h) = (v.w, v.h);
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (lon, lat) = px_to_lonlat(x as f64 + 0.5, y as f64 + 0.5, w, h);
let d = dir(lon, lat);
let th = dot(d, center).clamp(-1.0, 1.0).acos();
let mut f = (th / PI).powf(p);
if inverse {
f = 1.0 - f;
}
let axis = cross(center, d);
let al = dot(axis, axis).sqrt();
if al > 1e-9 {
let k = [axis[0] / al, axis[1] / al, axis[2] / al];
let px = &mut row[x * 4..x * 4 + 3];
for (c, v) in px.iter_mut().enumerate() {
let ang = ab[c] as f64 / 100.0 * 0.15 * f;
// rotate d about k by −ang (Rodrigues)
let (s, co) = (-ang).sin_cos();
let kd = cross(k, d);
let kdot = dot(k, d);
let r = [0, 1, 2].map(|i| d[i] * co + kd[i] * s + k[i] * kdot * (1.0 - co));
*v = sample_dir(&src, r)[c];
}
}
}
});
});
}
"vr_color_gradients" => {
let pts: Vec<(V3, [f32; 3])> = (1..=3)
.map(|i| {
let lon = fv(e, ["p1_lon", "p2_lon", "p3_lon"][i - 1], cx) as f64;
let lat = fv(e, ["p1_lat", "p2_lat", "p3_lat"][i - 1], cx) as f64;
(dir(lon.to_radians(), lat.to_radians()), lin(cv(e, ["c1", "c2", "c3"][i - 1], cx)))
})
.collect();
let expo = 1.0 + fv(e, "blend", cx) as f64 / 25.0;
let op = fv(e, "opacity", cx) / 100.0;
let mode = chv(e, "mode");
per_view(img, layout, |v| {
let (w, h) = (v.w, v.h);
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (lon, lat) = px_to_lonlat(x as f64 + 0.5, y as f64 + 0.5, w, h);
let d = dir(lon, lat);
let mut acc = [0.0f64; 3];
let mut ws = 0.0;
for (pd, c) in &pts {
let ang = dot(d, *pd).clamp(-1.0, 1.0).acos().max(1e-3);
let wgt = 1.0 / ang.powf(expo);
for k in 0..3 {
acc[k] += c[k] as f64 * wgt;
}
ws += wgt;
}
let g = acc.map(|v| (v / ws) as f32);
let p = &mut row[x * 4..x * 4 + 4];
let base = Image::unpremul([p[0], p[1], p[2], p[3]]);
let o = lerp3(base, blend_simple(mode, base, g), op);
let a = p[3] + (1.0 - p[3]) * op;
p.copy_from_slice(&[o[0] * a, o[1] * a, o[2] * a, a]);
}
});
});
}
"vr_digital_glitch" => {
let amp = fv(e, "amplitude", cx) / 100.0;
if amp <= 0.0 {
return;
}
let cplx = fv(e, "distortion", cx).max(1.0);
let rate = fv(e, "rate", cx) as f64;
let colour = fv(e, "color", cx) / 100.0;
let scan = fv(e, "scanlines", cx) / 100.0;
let seed = fv(e, "seed", cx).max(0.0) as u64;
let tick = (cx.seconds * rate).floor() as i64;
per_view(img, layout, |v| {
let src = v.clone();
let (w, h) = (v.w, v.h);
let band = (h as f32 / cplx).max(1.0);
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
let b = (y as f32 / band) as i64;
let r = hash01(b, tick, 0, seed);
let active = r < amp;
let shift = if active { (hash01(b, tick, 1, seed) - 0.5) * w as f32 * 0.3 * amp } else { 0.0 };
let cs = colour * amp * 12.0;
for x in 0..w {
let (fx, fy) = (x as f64 + 0.5 + shift as f64, y as f64 + 0.5);
let mut o = sample_wrap(&src, fx, fy);
if active && cs > 0.0 {
o[0] = sample_wrap(&src, fx + cs as f64, fy)[0];
o[2] = sample_wrap(&src, fx - cs as f64, fy)[2];
}
if scan > 0.0 && y % 2 == 1 {
for c in &mut o[..3] {
*c *= 1.0 - scan * 0.6;
}
}
row[x * 4..x * 4 + 4].copy_from_slice(&o);
}
});
});
}
"vr_fractal_noise" => {
let kind = chv(e, "fractal_type");
let invert = bv(e, "invert");
let contrast = fv(e, "contrast", cx) / 100.0;
let bright = fv(e, "brightness", cx) / 100.0;
let scale = (fv(e, "scale", cx) / 100.0).max(0.01);
let cplx = fv(e, "complexity", cx);
let evo = fv(e, "evolution", cx) / 360.0;
let op = fv(e, "opacity", cx) / 100.0;
let mode = chv(e, "mode");
let f = 4.0 / scale;
per_view(img, layout, |v| {
let (w, h) = (v.w, v.h);
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (lon, lat) = px_to_lonlat(x as f64 + 0.5, y as f64 + 0.5, w, h);
let d = dir(lon, lat);
let mut n = fbm(d[0] as f32 * f + evo * 0.31, d[1] as f32 * f + evo * 0.17, d[2] as f32 * f + evo * 0.11, cplx, 3);
n = match kind {
1 => n.abs().sqrt(),
2 => n.abs(),
_ => n * 0.5 + 0.5,
};
let mut g = ((n - 0.5) * contrast + 0.5 + bright).clamp(0.0, 1.0);
if invert {
g = 1.0 - g;
}
let gl = filmcraft_color::srgb_to_linear(g);
let p = &mut row[x * 4..x * 4 + 4];
let base = Image::unpremul([p[0], p[1], p[2], p[3]]);
let o = lerp3(base, blend_simple(mode, base, [gl; 3]), op);
let a = p[3] + (1.0 - p[3]) * op;
p.copy_from_slice(&[o[0] * a, o[1] * a, o[2] * a, a]);
}
});
});
}
"vr_plane_to_sphere" => {
let fov = (fv(e, "scale", cx) as f64).to_radians().clamp(1e-3, PI * 0.999);
let r = transpose(rotation(fv(e, "tilt", cx) as f64, fv(e, "pan", cx) as f64, fv(e, "roll", cx) as f64));
let feather = fv(e, "feather", cx) / 100.0;
per_view(img, layout, |v| {
let src = v.clone();
let (w, h) = (v.w, v.h);
let tx = (fov / 2.0).tan();
let ty = tx * h as f64 / w as f64;
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (lon, lat) = px_to_lonlat(x as f64 + 0.5, y as f64 + 0.5, w, h);
let d = mvec(&r, dir(lon, lat));
let mut o = [0.0f32; 4];
if d[2] > 1e-6 {
let (u, vv) = (d[0] / d[2] / tx, -d[1] / d[2] / ty);
if u.abs() <= 1.0 && vv.abs() <= 1.0 {
o = src.sample_bilinear_clamped(((u + 1.0) / 2.0 * w as f64) as f32, ((vv + 1.0) / 2.0 * h as f64) as f32);
if feather > 0.0 {
let edge = (1.0 - u.abs().max(vv.abs())) as f32;
let k = smoothstep(0.0, feather, edge);
o = o.map(|c| c * k);
}
}
}
row[x * 4..x * 4 + 4].copy_from_slice(&o);
}
});
});
}
"vr_projection" | "vr_rotate_sphere" => {
let r = rotation(fv(e, "tilt", cx) as f64, fv(e, "pan", cx) as f64, fv(e, "roll", cx) as f64);
let fov = if e.effect == "vr_projection" { fv(e, "fov", cx) as f64 } else { 360.0 };
let stretch = e.effect == "vr_projection" && bv(e, "stretch");
let ident = (0..3).all(|i| (0..3).all(|j| (r[i][j] - if i == j { 1.0 } else { 0.0 }).abs() < 1e-12));
if ident && (fov - 360.0).abs() < 1e-9 {
return;
}
let rt = transpose(r);
per_view(img, layout, |v| {
let src = v.clone();
let (w, h) = (v.w, v.h);
let (kx, ky) = (fov / 360.0, if stretch { (fov / 180.0).min(1.0) } else { 1.0 });
v.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (lon, lat) = px_to_lonlat(x as f64 + 0.5, y as f64 + 0.5, w, h);
let d = mvec(&rt, dir(lon * kx, lat * ky));
row[x * 4..x * 4 + 4].copy_from_slice(&sample_dir(&src, d));
}
});
});
}
_ => {}
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Lights & Glows (and the glow-like Legacy Alpha Glow, the procedural Lens Flare).
use filmcraft_project::EffectInstance;
use rayon::prelude::*;
use super::*;
use crate::effects::gaussian;
fn blurred(src: &Image, sigma: f32) -> Image {
let mut g = src.clone();
gaussian(&mut g, sigma, sigma, false);
g
}
/// Add `light` (premultiplied linear) to `img`: Add or Screen.
fn add_image(img: &mut Image, light: &Image, gain: [f32; 3], screen: bool) {
img.px.par_chunks_mut(4).zip(light.px.par_chunks(4)).for_each(|(p, l)| {
let add = [l[0] * gain[0], l[1] * gain[1], l[2] * gain[2]];
if screen {
let a = [add[0].min(1.0), add[1].min(1.0), add[2].min(1.0)];
for k in 0..3 {
p[k] = p[k] + a[k] - p[k].min(1.0) * a[k];
}
p[3] = p[3] + (1.0 - p[3]) * a[0].max(a[1]).max(a[2]);
} else {
add_light(p, add);
}
});
}
fn saturate_image(img: &mut Image, sat: f32) {
if (sat - 1.0).abs() < 1e-4 {
return;
}
img.px.par_chunks_mut(4).for_each(|p| {
let l = luma([p[0], p[1], p[2]]);
for v in &mut p[..3] {
*v = (l + (*v - l) * sat).max(0.0);
}
});
}
/// Replace the hue of a light buffer with `c` (keeping its luminance), by `k`.
fn tint(img: &mut Image, c: [f32; 3], k: f32) {
if k <= 0.0 {
return;
}
img.px.par_chunks_mut(4).for_each(|p| {
let l = luma([p[0], p[1], p[2]]);
for i in 0..3 {
p[i] += (l * c[i] * 1.5 - p[i]) * k;
}
});
}
/// Echo Glow: glow repeated in widening echoes that fade by Decay.
pub fn echo_glow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let hi = highlights(img, fv(e, "threshold", cx) / 100.0, 0.1);
let n = fv(e, "echoes", cx).clamp(1.0, 12.0) as usize;
let r = fv(e, "radius", cx) * cx.px_scale * 0.5;
let spread = fv(e, "spread", cx).max(1.0);
let inten = fv(e, "intensity", cx) / 100.0;
let decay = 1.0 - fv(e, "decay", cx) / 100.0;
let col = lin(cv(e, "color", cx));
let layers: Vec<(Image, f32)> = (0..n).into_par_iter().map(|i| (blurred(&hi, r * spread.powi(i as i32)), decay.powi(i as i32))).collect();
let mut acc = Image::new(img.w, img.h);
for (l, wgt) in &layers {
acc.px.par_iter_mut().zip(l.px.par_iter()).for_each(|(a, b)| *a += b * wgt);
}
let norm = inten / layers.iter().map(|l| l.1).sum::<f32>().max(1e-6) * 2.0;
tint(&mut acc, col, 1.0);
add_image(img, &acc, [norm; 3], false);
}
/// Sobel edge strength of display luma.
fn edges(img: &Image) -> Vec<f32> {
let w = img.w;
let mut out = vec![0.0f32; img.w * img.h];
out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let l = |dx: isize, dy: isize| {
let p = img.get_clamped(x as isize + dx, y as isize + dy);
filmcraft_color::linear_to_srgb(luma([p[0], p[1], p[2]]).clamp(0.0, 1.0)) * p[3].min(1.0) + 0.0
};
let gx = l(1, -1) + 2.0 * l(1, 0) + l(1, 1) - l(-1, -1) - 2.0 * l(-1, 0) - l(-1, 1);
let gy = l(-1, 1) + 2.0 * l(0, 1) + l(1, 1) - l(-1, -1) - 2.0 * l(0, -1) - l(1, -1);
*o = (gx * gx + gy * gy).sqrt() / 4.0;
}
});
out
}
/// Edge Glow: coloured glow along the picture's edges.
pub fn edge_glow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let th = fv(e, "threshold", cx) / 100.0;
let width = fv(e, "width", cx) * cx.px_scale;
let r = fv(e, "radius", cx) * cx.px_scale * 0.5;
let inten = fv(e, "intensity", cx) / 100.0;
let col = lin(cv(e, "color", cx));
let mut m: Vec<f32> = edges(img).into_iter().map(|v| smoothstep(th, th + 0.1, v)).collect();
if width > 1.0 {
m = blur_plane(&m, img.w, img.h, width * 0.5).into_iter().map(|v| (v * 3.0).min(1.0)).collect();
}
let core = m.clone();
let halo = blur_plane(&m, img.w, img.h, r);
let mut light = Image::new(img.w, img.h);
light.px.par_chunks_mut(4).enumerate().for_each(|(i, p)| {
let v = (core[i] * 0.6 + halo[i] * 1.6) * inten;
p.copy_from_slice(&[col[0] * v, col[1] * v, col[2] * v, v.min(1.0)]);
});
if bv(e, "only") {
*img = Image::new(img.w, img.h);
}
add_image(img, &light, [1.0; 3], false);
}
/// Sum of `a^k · src(p − k·d)` for k = 0…len (an exponentially decaying streak), by doubling.
fn streak(src: &Image, dir: (f32, f32), len: f32) -> Image {
let a = (-1.0 / len.max(1.0)).exp();
let mut s = src.clone();
let mut n = 1.0f32;
while n < len * 3.0 {
let prev = s.clone();
let k = a.powf(n);
let (dx, dy) = (dir.0 * n, dir.1 * n);
let w = s.w;
s.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let q = prev.sample_bilinear(x as f32 + 0.5 - dx, y as f32 + 0.5 - dy);
for c in 0..4 {
row[x * 4 + c] += q[c] * k;
}
}
});
n *= 2.0;
}
let norm = 1.0 - a;
s.px.par_iter_mut().for_each(|v| *v *= norm);
s
}
/// Glint: star streaks from the brightest points.
pub fn glint(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let hi = highlights(img, fv(e, "threshold", cx) / 100.0, 0.08);
let rays = [2usize, 4, 6, 8][chv(e, "rays").min(3) as usize];
let len = fv(e, "length", cx) * cx.px_scale;
let rot = fv(e, "rotation", cx).to_radians();
let inten = fv(e, "intensity", cx) / 100.0 * 3.0;
let col = lin(cv(e, "color", cx));
let colorize = fv(e, "colorize", cx) / 100.0;
let parts: Vec<Image> = (0..rays)
.into_par_iter()
.map(|i| {
let a = rot + std::f32::consts::TAU * i as f32 / rays as f32;
streak(&hi, (a.cos(), a.sin()), len * 0.35)
})
.collect();
let mut acc = Image::new(img.w, img.h);
for p in &parts {
acc.px.par_iter_mut().zip(p.px.par_iter()).for_each(|(a, b)| *a += b);
}
tint(&mut acc, col, colorize);
add_image(img, &acc, [inten * col[0].max(0.2), inten * col[1].max(0.2), inten * col[2].max(0.2)], false);
}
/// Light Leaks: drifting warm coloured light washes (procedural, seeded, animated by Speed).
pub fn light_leaks(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c1 = lin(cv(e, "c1", cx));
let c2 = lin(cv(e, "c2", cx));
let inten = fv(e, "intensity", cx) / 100.0;
let scale = (fv(e, "scale", cx) / 100.0).max(0.05) * img.w.max(img.h) as f32 * 0.6;
let dir = fv(e, "direction", cx).to_radians();
let t = (cx.seconds * fv(e, "speed", cx) as f64) as f32;
let seed = fv(e, "seed", cx).max(0.0) as u64;
let mode = chv(e, "mode");
let (dx, dy) = (dir.cos(), dir.sin());
let (w, h) = (img.w as f32, img.h as f32);
generate(img, |x, y, p| {
let (u, v) = (x / scale, y / scale);
// stronger towards the edge the leak enters from
let along = ((x / w - 0.5) * dx + (y / h - 0.5) * dy) * 2.0;
let bias = smoothstep(-0.2, 1.0, -along);
let n = fbm(u + dx * t * 0.3, v + dy * t * 0.3, t * 0.15, 3.0, seed) * 0.5 + 0.5;
let m = fbm(u * 0.7 + 5.2, v * 0.7, t * 0.1 + 9.0, 2.0, seed) * 0.5 + 0.5;
let k = smoothstep(0.35, 0.85, n) * bias * inten;
let light = lerp3(c1, c2, m).map(|c| c * k);
let base = Image::unpremul(p);
let o = match mode {
1 => [base[0] + light[0], base[1] + light[1], base[2] + light[2]],
2 => blend_simple(4, base, lerp3(base, light.map(|q| q + 0.5), k.min(1.0))),
_ => blend_simple(2, base, light),
};
let a = p[3] + (1.0 - p[3]) * k.min(1.0);
[o[0] * a, o[1] * a, o[2] * a, a]
});
}
/// RGB Split: red and blue sampled from opposite offsets (linear or radial from a centre).
pub fn rgb_split(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let amt = fv(e, "amount", cx) * cx.px_scale;
if amt.abs() < 1e-3 {
return;
}
let radial = chv(e, "mode") == 1;
let ang = fv(e, "angle", cx).to_radians();
let c = pv(e, "center", cx, img);
let blend = fv(e, "blend", cx) / 100.0;
let maxd = ((img.w as f64).hypot(img.h as f64) / 2.0) as f32;
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let (ox, oy) = if radial {
let (dx, dy) = (px - c.x as f32, py - c.y as f32);
(dx / maxd * amt, dy / maxd * amt)
} else {
(ang.cos() * amt, ang.sin() * amt)
};
let r = src.sample_bilinear(px - ox, py - oy);
let g = src.get(x, y);
let b = src.sample_bilinear(px + ox, py + oy);
let o = [r[0], g[1], b[2], r[3].max(g[3]).max(b[3])];
for k in 0..4 {
row[x * 4 + k] = o[k] + (g[k] - o[k]) * blend;
}
}
});
}
/// Volumetric Rays: light shafts radiating from a source point (radial blur of the highlights).
pub fn volumetric_rays(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = pv(e, "center", cx, img);
let len = fv(e, "length", cx) / 100.0;
let inten = fv(e, "intensity", cx) / 100.0;
let col = lin(cv(e, "color", cx));
let only = bv(e, "only");
let mut hi = highlights(img, fv(e, "threshold", cx) / 100.0, 0.1);
let (fw, fh) = (img.w, img.h);
let mut down = 1.0;
while hi.w > 960 {
hi = hi.downsample2();
down *= 2.0;
}
let (ccx, ccy) = ((c.x / down) as f32, (c.y / down) as f32);
const N: usize = 48;
let src = hi.clone();
let w = hi.w;
hi.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let mut acc = [0.0f32; 4];
let mut wsum = 0.0;
for i in 0..N {
let t = i as f32 / N as f32 * len;
let s = src.sample_bilinear(px + (ccx - px) * t, py + (ccy - py) * t);
let wgt = 1.0 - i as f32 / N as f32 * 0.6;
for k in 0..4 {
acc[k] += s[k] * wgt;
}
wsum += wgt;
}
for k in 0..4 {
row[x * 4 + k] = acc[k] / wsum;
}
}
});
let rays = if down > 1.0 { hi.transformed(fw, fh, &Affine::scale(fw as f64 / hi.w as f64, fh as f64 / hi.h as f64)) } else { hi };
if only {
*img = Image::new(fw, fh);
}
add_image(img, &rays, col.map(|v| v * inten * 2.5), false);
}
/// Wonder Glow: a rich multi-scale glow (three radii) with saturation and colour controls.
pub fn wonder_glow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let mut hi = highlights(img, fv(e, "threshold", cx) / 100.0, 0.15);
saturate_image(&mut hi, fv(e, "saturation", cx) / 100.0);
if bv(e, "use_color") {
tint(&mut hi, lin(cv(e, "color", cx)), 1.0);
}
let r = fv(e, "radius", cx) * cx.px_scale * 0.5;
let inten = fv(e, "intensity", cx) / 100.0;
let parts: Vec<(Image, f32)> = [(0.25f32, 0.45f32), (1.0, 0.35), (3.0, 0.2)].par_iter().map(|&(k, wgt)| (blurred(&hi, r * k), wgt)).collect();
let mut acc = Image::new(img.w, img.h);
for (p, wgt) in &parts {
acc.px.par_iter_mut().zip(p.px.par_iter()).for_each(|(a, b)| *a += b * wgt);
}
add_image(img, &acc, [inten * 1.5; 3], chv(e, "mode") == 1);
}
/// Lens Flare (26.5 rebuild): core glow, halo, ghosts along the line through the frame centre and
/// a lens-type-dependent streak; tinted by Flare Color, scaled by Size.
pub fn lens_flare(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = pv(e, "center", cx, img);
let br = fv(e, "brightness", cx) / 100.0;
let blend = fv(e, "blend", cx) / 100.0;
let lens = chv(e, "lens_type");
let tintc = lin(cv(e, "color", cx));
let size = (fv(e, "size", cx) / 100.0).max(0.05) as f64;
let ghosts = fv(e, "ghosts", cx).clamp(0.0, 8.0) as usize;
let (w, h) = (img.w as f64, img.h as f64);
let (mx, my) = (w / 2.0, h / 2.0);
let scale = w.max(h) * size;
let core_k = [18.0, 26.0, 14.0, 22.0][lens.min(3) as usize];
let ghost_list: Vec<(f64, f64, [f32; 3])> = (0..ghosts)
.map(|i| {
let t = 0.35 + i as f64 * 0.42 + hash01(i as i64, lens as i64, 0, 7) as f64 * 0.2;
let r = 0.02 + hash01(i as i64, lens as i64, 1, 7) as f64 * 0.07;
let hue = [[0.4, 0.6, 1.0], [1.0, 0.5, 0.3], [0.4, 1.0, 0.5], [0.9, 0.4, 1.0]][i % 4];
(t, r, hue)
})
.collect();
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let d = (px - c.x).hypot(py - c.y) / scale;
let core = 0.35 * (-d * core_k).exp() + 0.08 * (-d * 3.0).exp();
let mut add = [core as f32; 3];
if lens == 1 || lens == 2 {
let ring = (1.0 - ((d - 0.12) / 0.012).abs()).max(0.0) * 0.08;
for (k, v) in add.iter_mut().enumerate() {
*v += (ring * [0.6, 0.8, 1.0][k]) as f32;
}
}
if lens == 3 {
// anamorphic: long horizontal streak
let s = (-((py - c.y).abs() / scale) * 160.0).exp() * (-((px - c.x).abs() / scale) * 2.0).exp() * 0.5;
add[0] += s as f32 * 0.5;
add[1] += s as f32 * 0.7;
add[2] += s as f32;
}
for (t, rad, hue) in &ghost_list {
let gx = c.x + (mx - c.x) * 2.0 * t;
let gy = c.y + (my - c.y) * 2.0 * t;
let dd = (px - gx).hypot(py - gy) / scale;
let g = ((1.0 - ((dd - rad) / 0.01).abs()).max(0.0) * 0.12 + if dd < *rad { 0.05 } else { 0.0 }) as f32;
for k in 0..3 {
add[k] += hue[k] * g;
}
}
let p = &mut row[x * 4..x * 4 + 4];
let k = br * (1.0 - blend);
add_light(p, [add[0] * tintc[0] * k, add[1] * tintc[1] * k, add[2] * tintc[2] * k]);
}
});
}
/// Alpha Glow (Legacy): glow outward from the alpha edges, Start Color fading to End Color.
pub fn alpha_glow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let r = fv(e, "glow", cx) * cx.px_scale * 0.5;
if r <= 0.3 {
return;
}
let bright = fv(e, "brightness", cx) / 255.0;
let sc = lin(cv(e, "start_color", cx));
let ec = lin(cv(e, "end_color", cx));
let use_end = bv(e, "use_end");
let fade = bv(e, "fade_out");
let a = alpha_of(img);
let g = blur_plane(&a, img.w, img.h, r);
img.px.par_chunks_mut(4).enumerate().for_each(|(i, p)| {
let gv = (g[i] * 2.0).min(1.0);
let k = if fade {
gv * bright
} else if gv > 0.02 {
bright
} else {
0.0
};
if k <= 0.0 || p[3] >= 1.0 {
return;
}
let col = if use_end { lerp3(ec, sc, gv) } else { sc };
// glow behind the layer
let under = [col[0] * k, col[1] * k, col[2] * k, k];
let front = [p[0], p[1], p[2], p[3]];
let mut o = under;
over(&mut o, front);
p.copy_from_slice(&o);
});
}
+535
View File
@@ -0,0 +1,535 @@
//! CPU implementations of the Premiere 26 video-effect catalogue added in M5.11 (see
//! `filmcraft_project::effect::vfx` for the definitions), plus the rebuilt versions of a few core
//! effects (Ultra Key, Lens Flare, Noise, Mosaic, Simple Text, Track Matte Key).
//!
//! Conventions match [`crate::effects`]: images are premultiplied linear f32; spatial parameters
//! are full-resolution clip pixels scaled by `FxCtx::px_scale`; colour maths that artists expect
//! in display space (keys, limiters, CDL) runs on sRGB-encoded straight colour.
//!
//! Effects that need more than the current frame — Posterize Time and Echo (other frames), Track
//! Matte Key / Compound Blur / Gradient Wipe (another track), Auto Reframe and Warp Stabilizer
//! (frames for analysis, sequence geometry) — read it through [`FxEnv`]; without an environment
//! (isolated tests, adjustment layers) they leave the image unchanged.
mod blur;
mod color;
mod distort;
mod immersive;
mod lights;
mod stylize;
mod temporal;
mod text;
#[cfg(test)]
mod tests;
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::{EffectInstance, ParamValue, Project, Sequence, TrackItem};
use filmcraft_time::{Tick, TimeDisplay, format_time};
use rayon::prelude::*;
use crate::effects::FxCtx;
use crate::image::Image;
use crate::{RenderOptions, SourceProvider};
pub use temporal::{StabilizerPath, clear_stabilizer_cache, stabilizer_path};
/// What an effect may know about the clip beyond its own pixels.
pub trait FxEnv: Sync {
/// The clip's layer `dt` timeline seconds from now, with the effects before `upto` applied,
/// at the current working size. Temporal effects among those are skipped (no recursion).
fn frame(&self, dt: f64, upto: &EffectInstance) -> Option<Image>;
/// The clip's raw picture `dt` seconds from now at `scale` × its source size (analysis).
fn source_frame(&self, dt: f64, scale: f32) -> Option<Image>;
/// Video track `index` (0 = V1) rendered alone at this time, output-sized.
fn track(&self, index: usize) -> Option<Image>;
/// Maps working-layer pixels to output pixels (Motion included).
fn layer_to_output(&self) -> Affine;
/// Clip duration on the timeline, in seconds.
fn clip_seconds(&self) -> f64;
/// Seconds from the clip's start to now.
fn clip_offset(&self) -> f64;
fn frame_rate(&self) -> f64;
fn media_timecode(&self) -> String;
fn file_name(&self) -> String;
fn sequence_name(&self) -> String;
/// Full-resolution sequence frame size.
fn sequence_size(&self) -> (u32, u32);
/// Full-resolution clip source size.
fn source_size(&self) -> (u32, u32);
/// Identity of the clip and its source range (cache key for analyses).
fn clip_key(&self) -> u64;
}
/// The environment of a track item being rendered by [`crate::item_layer`].
pub(crate) struct ItemEnv<'a> {
pub project: &'a Project,
pub seq: &'a Sequence,
pub item: &'a TrackItem,
pub t: Tick,
pub opts: RenderOptions,
pub sources: &'a dyn SourceProvider,
pub want: f32,
pub layer_size: (usize, usize),
pub layer_to_output: Affine,
pub tc: &'a str,
}
fn resize(img: Image, w: usize, h: usize) -> Image {
if img.w == w && img.h == h {
return img;
}
let m = Affine::scale(w as f64 / img.w.max(1) as f64, h as f64 / img.h.max(1) as f64);
img.transformed(w, h, &m)
}
/// Effects that read other frames (skipped when rendering another frame for one of them).
pub fn is_temporal(id: &str) -> bool {
matches!(id, "posterize_time" | "echo" | "warp_stabilizer" | "auto_reframe")
}
impl FxEnv for ItemEnv<'_> {
fn frame(&self, dt: f64, upto: &EffectInstance) -> Option<Image> {
let t2 = self.t + Tick::from_seconds_f64(dt);
let base = crate::base_layer(self.project, self.seq, self.item, t2, self.opts, self.sources, self.want)?;
let mut img = resize(base, self.layer_size.0, self.layer_size.1);
let src = crate::source_size(self.project, self.item.item).unwrap_or((1, 1));
let cx = FxCtx {
t: self.item.effect_time_at(t2),
px_scale: img.w as f32 / src.0.max(1) as f32,
seconds: (t2 - self.item.start).seconds(),
timecode: self.tc,
clip_name: &self.item.name,
project: Some(self.project),
env: None,
};
for e in &self.item.effects {
if std::ptr::eq(e, upto) {
break;
}
if e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e) && !is_temporal(&e.effect) {
crate::mask::apply_effect(&mut img, e, &cx);
}
}
Some(img)
}
fn source_frame(&self, dt: f64, scale: f32) -> Option<Image> {
let t2 = self.t + Tick::from_seconds_f64(dt);
crate::base_layer(self.project, self.seq, self.item, t2, self.opts, self.sources, scale.clamp(1.0 / 64.0, 1.0))
}
fn track(&self, index: usize) -> Option<Image> {
if index >= self.seq.video_tracks.len() {
return None;
}
let o = RenderOptions { captions: false, working_output: true, depth: self.opts.depth + 1, ..self.opts };
Some(crate::render_seq_tracks(self.project, self.seq, self.t, o, self.sources, Some(index)))
}
fn layer_to_output(&self) -> Affine {
self.layer_to_output
}
fn clip_seconds(&self) -> f64 {
self.item.duration.seconds()
}
fn clip_offset(&self) -> f64 {
(self.t - self.item.start).seconds()
}
fn frame_rate(&self) -> f64 {
self.seq.settings.frame_rate.as_f64()
}
fn media_timecode(&self) -> String {
let s = &self.seq.settings;
format_time(self.item.source_time_at(self.t), s.frame_rate, s.drop_frame, TimeDisplay::Timecode, s.sample_rate as i64)
}
fn file_name(&self) -> String {
self.project.item(self.item.item).map(|p| p.name.clone()).unwrap_or_default()
}
fn sequence_name(&self) -> String {
self.project
.items
.values()
.find(|p| matches!(&p.kind, filmcraft_project::ItemKind::Sequence(s) if std::ptr::eq(&**s, self.seq)))
.map(|p| p.name.clone())
.unwrap_or_default()
}
fn sequence_size(&self) -> (u32, u32) {
(self.seq.settings.width, self.seq.settings.height)
}
fn source_size(&self) -> (u32, u32) {
crate::source_size(self.project, self.item.item).unwrap_or((self.seq.settings.width, self.seq.settings.height))
}
fn clip_key(&self) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for v in [
self.item.item.0,
self.item.start.0 as u64,
self.item.duration.0 as u64,
self.item.source_in.0 as u64,
self.item.speed.to_bits(),
self.item.reverse as u64,
] {
h = (h ^ v).wrapping_mul(0x0100_0000_01b3);
}
h
}
}
// ------------------------------------------------------------------ parameters
pub(crate) fn def_value(e: &EffectInstance, id: &str) -> Option<ParamValue> {
e.def().and_then(|d| d.param(id)).map(|p| p.default.clone())
}
pub(crate) fn fv(e: &EffectInstance, id: &str, cx: &FxCtx) -> f32 {
e.f64_at(id, cx.t) as f32
}
pub(crate) fn bv(e: &EffectInstance, id: &str) -> bool {
e.param(id).and_then(|p| p.value.as_bool()).or_else(|| def_value(e, id).and_then(|v| v.as_bool())).unwrap_or(false)
}
pub(crate) fn chv(e: &EffectInstance, id: &str) -> u32 {
match e.param(id).map(|p| p.value.clone()).or_else(|| def_value(e, id)) {
Some(ParamValue::Choice(c)) => c,
_ => 0,
}
}
pub(crate) fn cv(e: &EffectInstance, id: &str, cx: &FxCtx) -> [f32; 4] {
e.param(id).and_then(|p| p.value_at(cx.t).as_color()).or_else(|| def_value(e, id).and_then(|v| v.as_color())).unwrap_or([1.0; 4])
}
pub(crate) fn tv(e: &EffectInstance, id: &str) -> String {
match e.param(id).map(|p| p.value.clone()).or_else(|| def_value(e, id)) {
Some(ParamValue::Text(s)) => s,
_ => String::new(),
}
}
/// A point parameter in working pixels. Auto (NaN) components use the effect's auto position
/// (`filmcraft_project::effect::auto_point`) or the layer centre.
pub(crate) fn pv(e: &EffectInstance, id: &str, cx: &FxCtx, img: &Image) -> Vec2 {
let v = e.param(id).map(|p| p.vec2_at(cx.t)).or_else(|| def_value(e, id).and_then(|v| v.as_vec2())).unwrap_or(Vec2::new(f64::NAN, f64::NAN));
let (fx, fy) = filmcraft_project::effect::auto_point(&e.effect, id).unwrap_or((0.5, 0.5));
Vec2::new(if v.x.is_nan() { img.w as f64 * fx } else { v.x * cx.px_scale as f64 }, if v.y.is_nan() { img.h as f64 * fy } else { v.y * cx.px_scale as f64 })
}
/// A point offset parameter (no auto position) in working pixels.
pub(crate) fn offv(e: &EffectInstance, id: &str, cx: &FxCtx) -> Vec2 {
let v = e.param(id).map(|p| p.vec2_at(cx.t)).or_else(|| def_value(e, id).and_then(|v| v.as_vec2())).unwrap_or_default();
let k = cx.px_scale as f64;
Vec2::new(if v.x.is_nan() { 0.0 } else { v.x * k }, if v.y.is_nan() { 0.0 } else { v.y * k })
}
// ------------------------------------------------------------------ colour helpers
pub(crate) use crate::effects::{dec, enc};
#[inline]
pub(crate) fn luma(c: [f32; 3]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
#[inline]
pub(crate) fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
#[inline]
pub(crate) fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0).max(1e-6)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// sRGB-encoded straight colour param → linear RGB.
#[inline]
pub(crate) fn lin(c: [f32; 4]) -> [f32; 3] {
dec([c[0], c[1], c[2]])
}
/// Blend `src` (straight linear RGB) onto `base` (straight linear RGB) with one of
/// [`filmcraft_project::effect::SIMPLE_BLEND`] (Normal, Add, Screen, Multiply, Overlay).
#[inline]
pub(crate) fn blend_simple(mode: u32, base: [f32; 3], src: [f32; 3]) -> [f32; 3] {
let mut o = [0.0; 3];
for k in 0..3 {
let (b, s) = (base[k], src[k]);
o[k] = match mode {
1 => b + s,
2 => 1.0 - (1.0 - b.min(1.0)) * (1.0 - s.min(1.0)),
3 => b * s,
4 => {
if b < 0.5 {
2.0 * b * s
} else {
1.0 - 2.0 * (1.0 - b.min(1.0)) * (1.0 - s.min(1.0))
}
}
_ => s,
};
}
o
}
/// Premultiplied `src` over premultiplied `dst`.
#[inline]
pub(crate) fn over(dst: &mut [f32], src: [f32; 4]) {
let k = 1.0 - src[3];
for c in 0..4 {
dst[c] = src[c] + dst[c] * k;
}
}
/// Add light `add` (linear RGB) to a premultiplied pixel, keeping it opaque where light lands on
/// transparency (glows extend past alpha).
#[inline]
pub(crate) fn add_light(p: &mut [f32], add: [f32; 3]) {
let m = add[0].max(add[1]).max(add[2]).clamp(0.0, 1.0);
for k in 0..3 {
p[k] += add[k];
}
p[3] = p[3] + (1.0 - p[3]) * m;
}
// ------------------------------------------------------------------ noise
#[inline]
pub(crate) fn hash_u(x: i64, y: i64, z: i64, seed: u64) -> u64 {
let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F)
^ (z as u64).wrapping_mul(0x1656_67B1_9E37_79F9)
^ seed.wrapping_mul(0x27D4_EB2F_1656_67C5);
h ^= h >> 31;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 29;
h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
h ^ (h >> 32)
}
/// Uniform 0..1 from integer coordinates.
#[inline]
pub(crate) fn hash01(x: i64, y: i64, z: i64, seed: u64) -> f32 {
(hash_u(x, y, z, seed) >> 40) as f32 / (1u64 << 24) as f32
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
#[inline]
fn grad3(h: u64, x: f32, y: f32, z: f32) -> f32 {
// 12 edge directions of a cube (Perlin 2002)
match h % 12 {
0 => x + y,
1 => -x + y,
2 => x - y,
3 => -x - y,
4 => x + z,
5 => -x + z,
6 => x - z,
7 => -x - z,
8 => y + z,
9 => -y + z,
10 => y - z,
_ => -y - z,
}
}
/// 3-D gradient noise in about −1..1 (our own hash; Perlin's interpolant and gradient set).
pub(crate) fn noise3(x: f32, y: f32, z: f32, seed: u64) -> f32 {
let (xi, yi, zi) = (x.floor(), y.floor(), z.floor());
let (xf, yf, zf) = (x - xi, y - yi, z - zi);
let (xi, yi, zi) = (xi as i64, yi as i64, zi as i64);
let (u, v, w) = (fade(xf), fade(yf), fade(zf));
let g = |dx: i64, dy: i64, dz: i64| grad3(hash_u(xi + dx, yi + dy, zi + dz, seed), xf - dx as f32, yf - dy as f32, zf - dz as f32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
let x01 = l(g(0, 0, 1), g(1, 0, 1), u);
let x11 = l(g(0, 1, 1), g(1, 1, 1), u);
l(l(x00, x10, v), l(x01, x11, v), w) * 0.9
}
/// Fractal (fBm) noise with `octaves` (fractional octaves fade in), about −1..1.
pub(crate) fn fbm(x: f32, y: f32, z: f32, octaves: f32, seed: u64) -> f32 {
let n = octaves.clamp(1.0, 10.0);
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0, 0.0);
let mut i = 0;
while (i as f32) < n {
let k = (n - i as f32).min(1.0);
sum += noise3(x * freq, y * freq, z * freq + i as f32 * 17.13, seed.wrapping_add(i as u64)) * amp * k;
norm += amp * k;
amp *= 0.5;
freq *= 2.0;
i += 1;
}
sum / norm.max(1e-6)
}
/// Smooth 1-D noise over time (−1..1): for wiggle/shake channels.
pub(crate) fn noise1(t: f64, channel: u64, seed: u64) -> f32 {
noise3(t as f32, channel as f32 * 7.31 + 0.5, 0.37, seed)
}
// ------------------------------------------------------------------ image helpers
/// The alpha channel as its own buffer.
pub(crate) fn alpha_of(img: &Image) -> Vec<f32> {
img.px.par_chunks(4).map(|p| p[3]).collect()
}
/// Gaussian-blur a single-channel buffer (via a temporary image).
pub(crate) fn blur_plane(plane: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
if sigma <= 0.3 {
return plane.to_vec();
}
let mut im = Image { w, h, px: plane.iter().flat_map(|&v| [v, 0.0, 0.0, 0.0]).collect() };
crate::effects::gaussian(&mut im, sigma, sigma, false);
im.px.chunks_exact(4).map(|p| p[0]).collect()
}
/// Bright parts of an image above `threshold` (display luma), premultiplied linear, for glows.
pub(crate) fn highlights(img: &Image, threshold: f32, soft: f32) -> Image {
let mut out = img.clone();
out.px.par_chunks_mut(4).for_each(|p| {
let a = p[3];
if a <= 1e-6 {
p.fill(0.0);
return;
}
let l = crate::effects::enc([p[0] / a, p[1] / a, p[2] / a]);
let k = smoothstep(threshold, threshold + soft.max(1e-3), luma(l));
for v in p.iter_mut() {
*v *= k;
}
});
out
}
/// Fill an image from a per-pixel premultiplied colour function (x, y at pixel centres).
pub(crate) fn generate(img: &mut Image, f: impl Fn(f32, f32, [f32; 4]) -> [f32; 4] + Sync) {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = &mut row[x * 4..x * 4 + 4];
let o = f(x as f32 + 0.5, y as f32 + 0.5, [p[0], p[1], p[2], p[3]]);
p.copy_from_slice(&o);
}
});
}
/// Inverse-map warp with an affine (destination → source sampling).
pub(crate) fn affine_warp(img: &mut Image, m: &Affine) {
if m.is_identity() {
return;
}
*img = img.transformed(img.w, img.h, m);
}
/// Shift/rotate/scale a layer about `c`: the transform shared by the animated Transform presets.
pub(crate) fn place(img: &mut Image, c: Vec2, offset: Vec2, scale: f64, rot_deg: f64) {
let m = Affine::translate(c.x + offset.x, c.y + offset.y)
.then_apply(&Affine::rotate_deg(rot_deg))
.then_apply(&Affine::scale(scale, scale))
.then_apply(&Affine::translate(-c.x, -c.y));
affine_warp(img, &m);
}
/// Easing curves for [`filmcraft_project::effect::EASINGS`].
pub(crate) fn ease(kind: u32, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match kind {
1 => t * t,
2 => 1.0 - (1.0 - t) * (1.0 - t),
3 => t * t * (3.0 - 2.0 * t),
_ => t,
}
}
/// Progress through the clip (0 at its first frame, 1 at its last) when an environment is
/// available, else 0.
pub(crate) fn clip_progress(cx: &FxCtx) -> f64 {
match cx.env {
Some(env) => {
let d = env.clip_seconds();
if d <= 1e-9 { 0.0 } else { (cx.seconds / d).clamp(0.0, 1.0) }
}
None => 0.0,
}
}
// ------------------------------------------------------------------ dispatch
/// Apply `e` if it is one of the effects implemented here; returns `false` for anything else.
pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) -> bool {
let alias = |img: &mut Image, base: &str| {
let mut a = e.clone();
a.effect = base.to_string();
crate::effects::apply(img, &a, cx);
};
match e.effect.as_str() {
// Adjust / Color / Image Control / Keying / Utility colour
"lighting_effects" => color::lighting(img, e, cx),
"asc_cdl" => color::asc_cdl(img, e, cx),
"video_limiter" => color::video_limiter(img, e, cx),
"vignette" => color::vignette(img, e, cx),
"channel_mix" => color::channel_mix(img, e, cx),
"color_replace" => color::color_replace(img, e, cx),
"alpha_adjust" => color::alpha_adjust(img, e, cx),
"logo_cutout" => color::logo_cutout(img, e, cx),
"ultra_key" => color::ultra_key(img, e, cx),
"track_matte" => color::track_matte(img, e, cx),
"cineon_converter" => color::cineon(img, e, cx),
"noise" | "noise_legacy" => color::noise(img, e, cx),
// Blur & Sharpen
"bokeh_blur" => blur::bokeh(img, e, cx),
"channel_blur" => blur::channel_blur(img, e, cx),
"compound_blur" => blur::compound(img, e, cx),
"focus_blur" => blur::focus(img, e, cx),
"reduce_interlace_flicker" => blur::interlace_flicker(img, e, cx),
"gaussian_blur_legacy" => alias(img, "gaussian_blur"),
"directional_blur_legacy" => alias(img, "directional_blur"),
// Distort / Transform / Utility geometry
"corner_pin" => distort::corner_pin(img, e, cx),
"magnify" | "magnify_legacy" => distort::magnify(img, e, cx),
"spherize" => distort::spherize(img, e, cx),
"turbulent_displace" => distort::turbulent_displace(img, e, cx),
"rotate_3d" => distort::rotate_3d(img, e, cx),
"grow" | "shrink" => distort::grow(img, e, cx),
"move" => distort::move_fx(img, e, cx),
"spin" => distort::spin(img, e, cx),
"wiggle" => distort::wiggle(img, e, cx),
"camera_shake" => distort::camera_shake(img, e, cx),
"spacer" => distort::spacer(img, e, cx),
"clone" => distort::clone_fx(img, e, cx),
"auto_align" => distort::auto_align(img, e, cx),
"rounded_crop" => distort::rounded_crop(img, e, cx),
"mosaic" | "mosaic_legacy" => distort::mosaic(img, e, cx),
"twirl_legacy" => alias(img, "twirl"),
// Lights & Glows
"echo_glow" => lights::echo_glow(img, e, cx),
"edge_glow" => lights::edge_glow(img, e, cx),
"glint" => lights::glint(img, e, cx),
"light_leaks" => lights::light_leaks(img, e, cx),
"rgb_split" => lights::rgb_split(img, e, cx),
"volumetric_rays" => lights::volumetric_rays(img, e, cx),
"wonder_glow" => lights::wonder_glow(img, e, cx),
"lens_flare" => lights::lens_flare(img, e, cx),
"alpha_glow" => lights::alpha_glow(img, e, cx),
// Stylize / Perspective / Generate / Legacy & obsolete generators
"brush_strokes" => stylize::brush_strokes(img, e, cx),
"color_emboss" => stylize::color_emboss(img, e, cx),
"roughen_edges" => stylize::roughen_edges(img, e, cx),
"long_shadow" => stylize::long_shadow(img, e, cx),
"stroke" => stylize::stroke(img, e, cx),
"gradient" => stylize::gradient(img, e, cx),
"block_dissolve" => stylize::block_dissolve(img, e, cx),
"gradient_wipe_legacy" => stylize::gradient_wipe(img, e, cx),
"linear_wipe_legacy" => stylize::linear_wipe(img, e, cx),
"lightning" => stylize::lightning(img, e, cx),
"cell_pattern" => stylize::cell_pattern(img, e, cx),
"checkerboard" => stylize::checkerboard(img, e, cx),
"ellipse" => stylize::ellipse(img, e, cx),
"paint_bucket" => stylize::paint_bucket(img, e, cx),
"write_on" => stylize::write_on(img, e, cx),
// Time / analysis
"posterize_time" => temporal::posterize_time(img, e, cx),
"echo" => temporal::echo(img, e, cx),
"warp_stabilizer" => temporal::warp_stabilizer(img, e, cx),
"auto_reframe" => temporal::auto_reframe(img, e, cx),
// Text
"simple_text" => text::simple_text(img, e, cx),
"metadata_burnin" => text::metadata_burnin(img, e, cx),
id if id.starts_with("vr_") => immersive::apply(img, e, cx),
_ => return false,
}
true
}
+838
View File
@@ -0,0 +1,838 @@
//! Stylize, Perspective and generator effects (plus the Legacy and obsolete generators).
use std::collections::VecDeque;
use filmcraft_project::EffectInstance;
use filmcraft_time::Tick;
use rayon::prelude::*;
use super::*;
/// Brush Strokes: a painterly look from short directional strokes (angle jittered per cell).
pub fn brush_strokes(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let base_ang = fv(e, "angle", cx).to_radians();
let size = (fv(e, "size", cx) * cx.px_scale).max(0.0);
let len = fv(e, "length", cx) * cx.px_scale;
if len < 0.5 && size < 0.5 {
return;
}
let density = fv(e, "density", cx).max(0.05);
let rnd = fv(e, "randomness", cx);
let surface = chv(e, "surface");
let blend = fv(e, "blend", cx) / 100.0;
let cell = (len.max(2.0) / density).max(2.0);
let steps = (len.ceil() as usize).clamp(2, 24);
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let (cxi, cyi) = ((px / cell).floor() as i64, (py / cell).floor() as i64);
let jit = (hash01(cxi, cyi, 1, 77) - 0.5) * rnd * std::f32::consts::FRAC_PI_2;
let a = base_ang + jit;
let (dx, dy) = (a.cos(), -a.sin());
let (nx, ny) = (-dy, dx);
let mut acc = [0.0f32; 4];
let mut n = 0.0;
for i in 0..steps {
let t = (i as f32 / (steps - 1) as f32 - 0.5) * len;
for s in [-0.5f32, 0.0, 0.5] {
let q = src.sample_bilinear_clamped(px + dx * t + nx * s * size, py + dy * t + ny * s * size);
for k in 0..4 {
acc[k] += q[k];
}
n += 1.0;
}
}
// bristle texture across the stroke
let perp = px * nx + py * ny;
let bristle = 1.0 + 0.1 * noise3(perp / size.max(1.0), (px * dx + py * dy) / len.max(1.0), cxi as f32 * 0.37, 5);
let mut o = acc.map(|v| v / n);
for v in &mut o[..3] {
*v *= bristle;
}
let cov = (0.75 + 0.25 * hash01(cxi, cyi, 3, 77)).min(1.0);
let o = match surface {
1 => o.map(|v| v * cov),
2 | 3 => {
let bg = if surface == 2 { 1.0 } else { 0.0 };
let mut b = [bg, bg, bg, 1.0];
over(&mut b, o.map(|v| v * cov));
b
}
_ => o,
};
let orig = src.get(x, y);
for k in 0..4 {
row[x * 4 + k] = o[k] + (orig[k] - o[k]) * blend;
}
}
});
}
/// Color Emboss: emboss relief that keeps the picture's colours.
pub fn color_emboss(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let dir = (fv(e, "direction", cx) as f64).to_radians();
let relief = fv(e, "relief", cx) * cx.px_scale.max(0.25);
let contrast = fv(e, "contrast", cx) / 100.0;
let blend = fv(e, "blend", cx) / 100.0;
let (dx, dy) = ((dir.cos() as f32) * relief, (-dir.sin() as f32) * relief);
let src = img.clone();
img.map_rgb(|c, x, y| {
let a = src.sample_bilinear_clamped(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
let b = src.sample_bilinear_clamped(x as f32 + 0.5 - dx, y as f32 + 0.5 - dy);
let d = luma(Image::unpremul(a)) - luma(Image::unpremul(b));
let v = enc(c);
let o = v.map(|q| (q + d * contrast * 2.0).clamp(0.0, 1.0));
dec(lerp3(o, v, blend))
});
}
/// Roughen Edges: fractal-eroded alpha edges (Roughen, Cut, Spiky, Rusty, Photocopy…).
pub fn roughen_edges(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let kind = chv(e, "edge_type");
let border = fv(e, "border", cx) * cx.px_scale;
if border < 0.5 {
return;
}
let sharp = fv(e, "sharpness", cx).max(0.0);
let infl = fv(e, "influence", cx).clamp(0.0, 1.0);
let scale = (fv(e, "scale", cx) / 100.0 * 30.0 * cx.px_scale).max(0.5);
let stretch = 2f32.powf(fv(e, "stretch", cx));
let off = offv(e, "offset", cx);
let cplx = fv(e, "complexity", cx).clamp(1.0, 10.0);
let evo = fv(e, "evolution", cx) / 360.0;
let seed = fv(e, "seed", cx).max(0.0) as u64;
let ec = lin(cv(e, "edge_color", cx));
let colored = matches!(kind, 1 | 5 | 7);
let a = alpha_of(img);
let depth = blur_plane(&a, img.w, img.h, border * 0.5);
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let i = y * w + x;
let p = &mut row[x * 4..x * 4 + 4];
if p[3] <= 1e-6 {
continue;
}
let (u, v) = ((x as f32 + off.x as f32) / (scale * stretch), (y as f32 + off.y as f32) * stretch / scale);
let mut n = fbm(u, v, evo, cplx, seed);
if kind == 3 {
n = 1.0 - 2.0 * n.abs();
}
let d = depth[i];
let mut k = ((d - 0.5 + n * infl * 0.5) * (1.0 + sharp * 4.0) + 0.5).clamp(0.0, 1.0);
if kind == 2 {
k = if k > 0.5 { 1.0 } else { 0.0 };
}
if matches!(kind, 4..=7) {
// rust / photocopy: holes eaten into the border region
let hole = fbm(u * 2.3 + 11.0, v * 2.3, evo, cplx, seed + 1);
if d < 0.97 && hole > 0.25 {
k *= 1.0 - smoothstep(0.25, 0.4, hole) * (1.0 - d);
}
}
if colored && d < 0.97 {
let t = (1.0 - d) * 1.5;
let c = Image::unpremul([p[0], p[1], p[2], p[3]]);
let col = lerp3(c, ec, t.min(1.0));
for c in 0..3 {
p[c] = col[c] * p[3];
}
}
if matches!(kind, 6 | 7) {
let c = Image::unpremul([p[0], p[1], p[2], p[3]]);
let l = if luma(enc(c)) > 0.5 { 1.0 } else { 0.0 };
if kind == 6 {
for c in 0..3 {
p[c] = l * p[3];
}
}
}
for v in p.iter_mut() {
*v *= k;
}
}
});
}
/// Long Shadow: an extruded, optionally fading shadow behind the layer's alpha. The per-pixel
/// step distance to the shape along the shadow direction is found by doubling (min-plus).
pub fn long_shadow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let ang = (fv(e, "angle", cx) as f64).to_radians();
let len = fv(e, "length", cx) * cx.px_scale;
if len < 0.5 {
return;
}
let col = lin(cv(e, "color", cx));
let op = fv(e, "opacity", cx) / 100.0;
let fade = bv(e, "fade");
let only = bv(e, "only");
let (dx, dy) = (ang.sin() as f32, -ang.cos() as f32);
let (w, h) = (img.w, img.h);
let inf = f32::INFINITY;
let mut d: Vec<f32> = img.px.par_chunks(4).map(|p| if p[3] > 0.5 { 0.0 } else { inf }).collect();
let mut s = 1.0f32;
while s <= len * 2.0 {
let prev = d.clone();
d.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let (sx, sy) = ((x as f32 - dx * s).round(), (y as f32 - dy * s).round());
if sx >= 0.0 && sy >= 0.0 && (sx as usize) < w && (sy as usize) < h {
let q = prev[sy as usize * w + sx as usize] + s;
if q < *v {
*v = q;
}
}
}
});
s *= 2.0;
}
let mut sh: Vec<f32> = d.par_iter().map(|&v| if v <= len { (if fade { 1.0 - v / len } else { 1.0 }) * op } else { 0.0 }).collect();
sh = blur_plane(&sh, w, h, 0.6);
img.px.par_chunks_mut(4).zip(sh.par_iter()).for_each(|(p, &a)| {
let mut o = [col[0] * a, col[1] * a, col[2] * a, a];
if !only {
over(&mut o, [p[0], p[1], p[2], p[3]]);
}
p.copy_from_slice(&o);
});
}
/// 1-D squared Euclidean distance transform: lower envelope of parabolas (Felzenszwalb &
/// Huttenlocher, "Distance Transforms of Sampled Functions", 2012).
fn edt_1d(f: &[f64], out: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut v = vec![0usize; n];
let mut z = vec![0f64; n + 1];
let mut k = 0usize;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let isect = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
for q in 1..n {
let mut s = isect(q, v[k]);
while s <= z[k] {
k -= 1;
s = isect(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let p = v[k];
*o = (q as f64 - p as f64).powi(2) + f[p];
}
}
/// Euclidean distance from every pixel to the nearest pixel where `inside` is true.
pub(crate) fn distance_to(inside: &[bool], w: usize, h: usize) -> Vec<f32> {
let big = 1e12f64;
let mut g: Vec<f64> = inside.iter().map(|&b| if b { 0.0 } else { big }).collect();
g.par_chunks_mut(w).for_each(|row| {
let f = row.to_vec();
edt_1d(&f, row);
});
let cols: Vec<Vec<f64>> = (0..w)
.into_par_iter()
.map(|x| {
let f: Vec<f64> = (0..h).map(|y| g[y * w + x]).collect();
let mut o = vec![0f64; h];
edt_1d(&f, &mut o);
o
})
.collect();
let mut out = vec![0f32; w * h];
for (x, c) in cols.iter().enumerate() {
for y in 0..h {
out[y * w + x] = c[y].sqrt() as f32;
}
}
out
}
/// Stroke: a solid outline around the layer's alpha (outside, centred or inside the edge).
pub fn stroke(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let width = fv(e, "width", cx) * cx.px_scale;
if width <= 0.0 {
return;
}
let col = lin(cv(e, "color", cx));
let op = fv(e, "opacity", cx) / 100.0;
let pos = chv(e, "position");
let only = bv(e, "only");
// pad by one pixel so the frame border counts as an edge
let (w, h) = (img.w + 2, img.h + 2);
let mut inside = vec![false; w * h];
for y in 0..img.h {
for x in 0..img.w {
inside[(y + 1) * w + x + 1] = img.px[(y * img.w + x) * 4 + 3] > 0.5;
}
}
let outside: Vec<bool> = inside.iter().map(|b| !b).collect();
let d_out = distance_to(&inside, w, h);
let d_in = distance_to(&outside, w, h);
let iw = img.w;
img.px.par_chunks_mut(iw * 4).enumerate().for_each(|(y, row)| {
for x in 0..iw {
let i = (y + 1) * w + x + 1;
// signed distance to the edge, positive outside (pixel centres sit ±0.5 from it)
let s = if inside[i] { -(d_in[i] - 0.5) } else { d_out[i] - 0.5 };
let (lo, hi) = match pos {
0 => (0.0, width),
1 => (-width / 2.0, width / 2.0),
_ => (-width, 0.0),
};
let k = ((s - lo + 0.5).clamp(0.0, 1.0)).min((hi - s + 0.5).clamp(0.0, 1.0)) * op;
let p = &mut row[x * 4..x * 4 + 4];
if only {
p.copy_from_slice(&[col[0] * k, col[1] * k, col[2] * k, k]);
} else if k > 0.0 {
over(p, [col[0] * k, col[1] * k, col[2] * k, k]);
}
}
});
}
/// Gradient (26.2): linear / radial / reflected / diamond ramp with midpoint and scatter.
pub fn gradient(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let s = pv(e, "start", cx, img);
let en = pv(e, "end", cx, img);
let sc = cv(e, "start_color", cx);
let ec = cv(e, "end_color", cx);
let shape = chv(e, "shape");
let scatter = fv(e, "scatter", cx) / 100.0;
let mid = (fv(e, "midpoint", cx) / 100.0).clamp(0.01, 0.99);
let gamma = (0.5f32).ln() / mid.ln();
let blend = fv(e, "blend", cx) / 100.0;
let (vx, vy) = ((en.x - s.x) as f32, (en.y - s.y) as f32);
let l2 = (vx * vx + vy * vy).max(1e-6);
let l = l2.sqrt();
generate(img, |x, y, p| {
let (dx, dy) = (x - s.x as f32, y - s.y as f32);
let along = (dx * vx + dy * vy) / l2;
let across = (-dx * vy + dy * vx) / l2;
let mut t = match shape {
1 => (dx * dx + dy * dy).sqrt() / l,
2 => along.abs(),
3 => along.abs() + across.abs(),
_ => along,
};
if scatter > 0.0 {
t += (hash01(x as i64, y as i64, 0, 13) - 0.5) * scatter * 0.1;
}
let t = t.clamp(0.0, 1.0).powf(gamma);
let c = dec(lerp3([sc[0], sc[1], sc[2]], [ec[0], ec[1], ec[2]], t));
let a = sc[3] + (ec[3] - sc[3]) * t;
let g = [c[0] * a, c[1] * a, c[2] * a, a];
[0, 1, 2, 3].map(|k| g[k] + (p[k] - g[k]) * blend)
});
}
/// Block Dissolve (Legacy): random blocks disappear as Transition Completion rises.
pub fn block_dissolve(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = fv(e, "completion", cx) / 100.0;
if c <= 0.0 {
return;
}
let bw = (fv(e, "block_w", cx) * cx.px_scale).max(1.0);
let bh = (fv(e, "block_h", cx) * cx.px_scale).max(1.0);
let fe = fv(e, "feather", cx) / 100.0 * 0.5;
let soft = bv(e, "soft");
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let r = hash01((x as f32 / bw) as i64, (y as f32 / bh) as i64, 0, 41);
let a = if soft && fe > 0.0 {
smoothstep(c - fe, c + fe, r)
} else if r >= c {
1.0
} else {
0.0
};
if c >= 1.0 || a < 1.0 {
let a = if c >= 1.0 { 0.0 } else { a };
for v in &mut row[x * 4..x * 4 + 4] {
*v *= a;
}
}
}
});
}
/// Alpha from a wipe source g (0..1): pixels whose g is below the completion vanish first.
#[inline]
fn wipe_alpha(g: f32, c: f32, soft: f32) -> f32 {
if soft <= 1e-4 {
return if c <= 0.0 || g >= c { 1.0 } else { 0.0 };
}
let t = c * (1.0 + soft);
((g - t + soft) / soft).clamp(0.0, 1.0)
}
/// Gradient Wipe (Legacy): reveals by the luminance of a gradient layer (another track, or this
/// layer's own luminance).
pub fn gradient_wipe(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = fv(e, "completion", cx) / 100.0;
if c <= 0.0 {
return;
}
let soft = fv(e, "softness", cx) / 100.0;
let invert = bv(e, "invert");
let layer = chv(e, "layer") as usize;
let placement = chv(e, "placement");
let track = if layer > 0 { cx.env.and_then(|env| env.track(layer - 1).map(|t| (t, env.layer_to_output()))) } else { None };
let own = img.clone();
let w = img.w;
let (iw, ih) = (img.w as f32, img.h as f32);
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let s = match &track {
Some((t, m)) => match placement {
0 => t.get(x % t.w, y % t.h),
1 => t.sample_bilinear(px - iw / 2.0 + t.w as f32 / 2.0, py - ih / 2.0 + t.h as f32 / 2.0),
_ => {
let o = m.apply(Vec2::new(px as f64, py as f64));
t.sample_bilinear_clamped(o.x as f32, o.y as f32)
}
},
None => own.get(x, y),
};
let mut g = filmcraft_color::linear_to_srgb(luma([s[0], s[1], s[2]]).clamp(0.0, 1.0));
if invert {
g = 1.0 - g;
}
let a = wipe_alpha(g, c, soft);
for v in &mut row[x * 4..x * 4 + 4] {
*v *= a;
}
}
});
}
/// Linear Wipe (Legacy): a straight edge sweeps across at Wipe Angle.
pub fn linear_wipe(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = fv(e, "completion", cx) / 100.0;
if c <= 0.0 {
return;
}
let ang = fv(e, "angle", cx).to_radians();
let (dx, dy) = (ang.sin(), -ang.cos());
let (w, h) = (img.w as f32, img.h as f32);
// extent of the frame along the wipe direction
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(x, y)| x * dx + y * dy);
let (lo, hi) = (corners.iter().copied().fold(f32::INFINITY, f32::min), corners.iter().copied().fold(f32::NEG_INFINITY, f32::max));
let ext = (hi - lo).max(1.0);
let soft = fv(e, "feather", cx) * cx.px_scale / ext;
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let g = ((x as f32 + 0.5) * dx + (y as f32 + 0.5) * dy - lo) / ext;
let a = wipe_alpha(g, c, soft);
for v in &mut row[x * 4..x * 4 + 4] {
*v *= a;
}
}
});
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f32 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u64 << 24) as f32
}
fn signed(&mut self) -> f32 {
self.next() * 2.0 - 1.0
}
}
/// A bolt between two points: midpoint displacement, `levels` of detail.
fn bolt(a: (f32, f32), b: (f32, f32), segs: usize, amp: f32, levels: usize, detail_amp: f32, rng: &mut Rng) -> Vec<(f32, f32)> {
let mut pts = vec![a];
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt().max(1e-3);
let (nx, ny) = (-dy / len, dx / len);
for i in 1..segs {
let t = i as f32 / segs as f32;
let o = rng.signed() * amp;
pts.push((a.0 + dx * t + nx * o, a.1 + dy * t + ny * o));
}
pts.push(b);
let mut a2 = amp / segs as f32 * 2.0 * detail_amp.max(0.05) * 4.0;
for _ in 0..levels {
let mut np = Vec::with_capacity(pts.len() * 2);
for w in pts.windows(2) {
let (p, q) = (w[0], w[1]);
let (ex, ey) = (q.0 - p.0, q.1 - p.1);
let l = (ex * ex + ey * ey).sqrt().max(1e-3);
let o = rng.signed() * a2.min(l * 0.5);
np.push(p);
np.push(((p.0 + q.0) / 2.0 - ey / l * o, (p.1 + q.1) / 2.0 + ex / l * o));
}
np.push(*pts.last().expect("bolt"));
pts = np;
a2 *= 0.5;
}
pts
}
/// Lightning (obsolete): a seeded, re-striking bolt with branches, glow and core colours.
pub fn lightning(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let s = pv(e, "start", cx, img);
let en = pv(e, "end", cx, img);
let segs = fv(e, "segments", cx).clamp(1.0, 100.0) as usize;
let dist = ((en.x - s.x).hypot(en.y - s.y)) as f32;
let amp = fv(e, "amplitude", cx) / 100.0 * dist * 0.5;
let levels = fv(e, "detail", cx).clamp(0.0, 6.0) as usize;
let damp = fv(e, "detail_amplitude", cx);
let branching = fv(e, "branching", cx);
let speed = fv(e, "speed", cx) as f64;
let width = (fv(e, "width", cx) * cx.px_scale).max(0.5);
let core = fv(e, "core", cx);
let outc = lin(cv(e, "outside", cx));
let inc = lin(cv(e, "inside", cx));
let mode = chv(e, "mode");
let strike = (cx.seconds * speed / 10.0).floor() as u64;
let mut rng = Rng(fv(e, "seed", cx).max(0.0) as u64 * 7919 + strike);
let main = bolt((s.x as f32, s.y as f32), (en.x as f32, en.y as f32), segs, amp, levels, damp, &mut rng);
let mut lines: Vec<(Vec<(f32, f32)>, f32)> = vec![(main.clone(), width)];
for i in 1..main.len().saturating_sub(1) {
if rng.next() < branching * 0.25 {
let p = main[i];
let (dx, dy) = (main[i + 1].0 - main[i - 1].0, main[i + 1].1 - main[i - 1].1);
let a = dy.atan2(dx) + rng.signed() * 0.6;
let l = dist * (0.15 + rng.next() * 0.25);
let q = (p.0 + a.cos() * l, p.1 + a.sin() * l);
lines.push((bolt(p, q, 4, amp * 0.4, levels.min(3), damp, &mut rng), width * 0.6));
}
}
let (w, h) = (img.w, img.h);
let mut dmap = vec![f32::INFINITY; w * h];
for (pts, lw) in &lines {
for seg in pts.windows(2) {
let (a, b) = (seg[0], seg[1]);
let r = lw * 3.0;
let (x0, x1) = ((a.0.min(b.0) - r).floor().max(0.0) as usize, ((a.0.max(b.0) + r).ceil().max(0.0) as usize).min(w));
let (y0, y1) = ((a.1.min(b.1) - r).floor().max(0.0) as usize, ((a.1.max(b.1) + r).ceil().max(0.0) as usize).min(h));
let (ex, ey) = (b.0 - a.0, b.1 - a.1);
let l2 = (ex * ex + ey * ey).max(1e-6);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let t = (((px - a.0) * ex + (py - a.1) * ey) / l2).clamp(0.0, 1.0);
let d = ((px - a.0 - ex * t).powi(2) + (py - a.1 - ey * t).powi(2)).sqrt() / lw;
let i = y * w + x;
if d < dmap[i] {
dmap[i] = d;
}
}
}
}
}
img.px.par_chunks_mut(4).zip(dmap.par_iter()).for_each(|(p, &d)| {
if !d.is_finite() {
return;
}
let glow = (-(d * 1.6)).exp() * 0.9;
let c = 1.0 - smoothstep(core * 0.5, core * 0.5 + 0.2, d);
let col = lerp3(outc.map(|v| v * glow), inc, c);
let a = glow.max(c).min(1.0);
let base = Image::unpremul([p[0], p[1], p[2], p[3]]);
let o = match mode {
0 => {
let mut q = [p[0], p[1], p[2], p[3]];
over(&mut q, [col[0] * a, col[1] * a, col[2] * a, a]);
p.copy_from_slice(&q);
return;
}
m => blend_simple(m, base, col),
};
let na = p[3] + (1.0 - p[3]) * a;
let mixed = lerp3(base, o, a);
p.copy_from_slice(&[mixed[0] * na, mixed[1] * na, mixed[2] * na, na]);
});
}
/// Cell Pattern (obsolete): Worley (cellular) noise patterns.
pub fn cell_pattern(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let pat = chv(e, "pattern");
let invert = bv(e, "invert");
let contrast = fv(e, "contrast", cx) / 100.0;
let overflow = chv(e, "overflow");
let disperse = fv(e, "disperse", cx).clamp(0.0, 1.5);
let size = (fv(e, "size", cx) * cx.px_scale).max(1.0);
let off = offv(e, "offset", cx);
let evo = if pat == 3 { 0.0 } else { fv(e, "evolution", cx).to_radians() };
let seed = fv(e, "seed", cx).max(0.0) as u64;
generate(img, |x, y, _| {
let (u, v) = ((x + off.x as f32) / size, (y + off.y as f32) / size);
let (ci, cj) = (u.floor() as i64, v.floor() as i64);
let (mut f1, mut f2, mut id) = (f32::INFINITY, f32::INFINITY, 0.0f32);
for dj in -1..=1 {
for di in -1..=1 {
let (gi, gj) = (ci + di, cj + dj);
let ph = hash01(gi, gj, 9, seed) * std::f32::consts::TAU;
let jx = 0.5 + (hash01(gi, gj, 0, seed) - 0.5) * disperse + (evo + ph).cos() * 0.15 * disperse;
let jy = 0.5 + (hash01(gi, gj, 1, seed) - 0.5) * disperse + (evo + ph).sin() * 0.15 * disperse;
let d = ((gi as f32 + jx - u).powi(2) + (gj as f32 + jy - v).powi(2)).sqrt();
if d < f1 {
f2 = f1;
f1 = d;
id = hash01(gi, gj, 2, seed);
} else if d < f2 {
f2 = d;
}
}
}
let mut val = match pat {
0 => (1.0 - f1 * 1.4).max(0.0),
1 => (f2 - f1) * 2.0,
2 | 3 => id,
4 => id * smoothstep(0.0, 0.12, f2 - f1),
5 => (f2 - f1) / (f2 + f1).max(1e-4) * 2.0,
6 => ((f2 - f1) * 2.0 + id) * 0.5,
_ => 1.0 - smoothstep(0.0, 0.12, (f1 - 0.35).abs()),
};
val = (val - 0.5) * contrast + 0.5;
val = match overflow {
1 => 1.0 / (1.0 + (-(val - 0.5) * 4.0).exp()),
2 => {
let m = val.rem_euclid(2.0);
if m > 1.0 { 2.0 - m } else { m }
}
_ => val.clamp(0.0, 1.0),
};
if invert {
val = 1.0 - val;
}
let g = filmcraft_color::srgb_to_linear(val);
[g, g, g, 1.0]
});
}
/// Checkerboard (obsolete): a checkerboard of Color and transparency.
pub fn checkerboard(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let a = pv(e, "anchor", cx, img);
let cw = (fv(e, "width", cx) * cx.px_scale).max(1.0);
let ch = if bv(e, "square") { cw } else { (fv(e, "height", cx) * cx.px_scale).max(1.0) };
let fe = fv(e, "feather", cx) * cx.px_scale;
let col = lin(cv(e, "color", cx));
let op = fv(e, "opacity", cx) / 100.0;
let mode = chv(e, "mode");
generate(img, |x, y, p| {
let (u, v) = ((x - a.x as f32) / cw, (y - a.y as f32) / ch);
let par = ((u.floor() as i64 + v.floor() as i64).rem_euclid(2)) as f32;
let edge = {
let du = (u - u.round()).abs() * cw;
let dv = (v - v.round()).abs() * ch;
if fe > 0.0 { smoothstep(0.0, fe, du.min(dv)) } else { 1.0 }
};
let k = par * edge * op;
match mode {
0 => [col[0] * k, col[1] * k, col[2] * k, k],
1 => {
let mut q = p;
over(&mut q, [col[0] * k, col[1] * k, col[2] * k, k]);
q
}
m => {
let base = Image::unpremul(p);
let bm = [0u32, 0, 1, 3, 2, 4][m.min(5) as usize];
let o = lerp3(base, blend_simple(bm, base, col), k);
[o[0] * p[3], o[1] * p[3], o[2] * p[3], p[3]]
}
}
});
}
/// Ellipse (obsolete): an anti-aliased elliptical ring, Inside Color at its core.
pub fn ellipse(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let c = pv(e, "center", cx, img);
let (rx, ry) = ((fv(e, "width", cx) * cx.px_scale / 2.0).max(0.5), (fv(e, "height", cx) * cx.px_scale / 2.0).max(0.5));
let th = (fv(e, "thickness", cx) * cx.px_scale).max(0.5);
let soft = fv(e, "softness", cx) / 100.0;
let inc = lin(cv(e, "inside", cx));
let outc = lin(cv(e, "outside", cx));
let comp = bv(e, "composite");
let rmin = rx.min(ry);
generate(img, |x, y, p| {
let (dx, dy) = ((x - c.x as f32) / rx, (y - c.y as f32) / ry);
let d = ((dx * dx + dy * dy).sqrt() - 1.0).abs() * rmin;
let half = th / 2.0;
let a = 1.0 - smoothstep(half * (1.0 - soft) - 0.5, half + 0.5, d);
let inner = 1.0 - smoothstep(0.0, half, d);
let col = lerp3(outc, inc, inner);
let s = [col[0] * a, col[1] * a, col[2] * a, a];
if comp {
let mut q = p;
over(&mut q, s);
q
} else {
s
}
});
}
/// Paint Bucket (obsolete): flood fill from Fill Point within Tolerance.
pub fn paint_bucket(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let pt = pv(e, "point", cx, img);
let (w, h) = (img.w, img.h);
let (sx, sy) = (pt.x.floor() as isize, pt.y.floor() as isize);
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
return;
}
let sel = chv(e, "selector");
let tol = fv(e, "tolerance", cx) / 255.0;
let col = lin(cv(e, "color", cx));
let op = fv(e, "opacity", cx) / 100.0;
let mode = chv(e, "mode");
let px = |i: usize| -> [f32; 4] {
let p = &img.px[i * 4..i * 4 + 4];
let c = enc(Image::unpremul([p[0], p[1], p[2], p[3]]));
[c[0], c[1], c[2], p[3]]
};
let seed = px(sy as usize * w + sx as usize);
let similar = |q: [f32; 4]| -> bool {
match sel {
0 => (0..4).all(|k| (q[k] - seed[k]).abs() <= tol),
1 => (0..3).all(|k| (q[k] - seed[k]).abs() <= tol),
2 => q[3] <= tol.max(1e-3),
_ => (q[3] - seed[3]).abs() <= tol,
}
};
let mut fill = vec![0f32; w * h];
let mut queue = VecDeque::new();
let start = sy as usize * w + sx as usize;
if similar(seed) {
fill[start] = 1.0;
queue.push_back(start);
}
while let Some(i) = queue.pop_front() {
let (x, y) = (i % w, i / w);
let mut visit = |j: usize| {
if fill[j] == 0.0 && similar(px(j)) {
fill[j] = 1.0;
queue.push_back(j);
}
};
if x > 0 {
visit(i - 1);
}
if x + 1 < w {
visit(i + 1);
}
if y > 0 {
visit(i - w);
}
if y + 1 < h {
visit(i + w);
}
}
if bv(e, "invert") {
fill.iter_mut().for_each(|v| *v = 1.0 - *v);
}
let fill = blur_plane(&fill, w, h, 0.5);
img.px.par_chunks_mut(4).zip(fill.par_iter()).for_each(|(p, &f)| {
let k = f * op;
if k <= 0.0 {
return;
}
let s = [col[0] * k, col[1] * k, col[2] * k, k];
match mode {
1 => {
let mut q = s;
over(&mut q, [p[0], p[1], p[2], p[3]]);
p.copy_from_slice(&q);
}
2..=4 => {
let base = Image::unpremul([p[0], p[1], p[2], p[3]]);
let bm = [0u32, 0, 1, 3, 2][mode as usize];
let o = lerp3(base, blend_simple(bm, base, col), k);
for c in 0..3 {
p[c] = o[c] * p[3];
}
}
_ => over(p, s),
}
});
}
/// Write-on (obsolete): paints the animated Brush Position's path up to now.
pub fn write_on(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let Some(param) = e.param("brush") else { return };
let col = lin(cv(e, "color", cx));
let r = (fv(e, "size", cx) * cx.px_scale / 2.0).max(0.5);
let hard = fv(e, "hardness", cx) / 100.0;
let op = fv(e, "opacity", cx) / 100.0;
let stroke_len = fv(e, "stroke_length", cx) as f64;
let spacing = (fv(e, "spacing", cx) as f64).max(0.001);
let style = chv(e, "style");
let elapsed = cx.seconds.max(0.0);
let n = ((elapsed / spacing).floor() as usize).min(20_000);
let first = if stroke_len > 0.0 { ((elapsed - stroke_len) / spacing).ceil().max(0.0) as usize } else { 0 };
let to_px = |t: f64| {
let mt = cx.t - Tick::from_seconds_f64(elapsed - t);
let v = param.vec2_at(mt);
let (fx, fy) = (
if v.x.is_nan() { img.w as f64 / 2.0 } else { v.x * cx.px_scale as f64 },
if v.y.is_nan() { img.h as f64 / 2.0 } else { v.y * cx.px_scale as f64 },
);
(fx as f32, fy as f32)
};
let mut stamps: Vec<(f32, f32)> = Vec::new();
let mut prev: Option<(f32, f32)> = None;
for i in first..=n {
let p = to_px(i as f64 * spacing);
if let Some(q) = prev {
let d = ((p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)).sqrt();
let steps = (d / (r * 0.5)).ceil() as usize;
for s in 1..steps.min(2000) {
let t = s as f32 / steps as f32;
stamps.push((q.0 + (p.0 - q.0) * t, q.1 + (p.1 - q.1) * t));
}
}
stamps.push(p);
prev = Some(p);
}
let (w, h) = (img.w, img.h);
let mut cov = vec![0f32; w * h];
for (sx, sy) in stamps {
let (x0, x1) = ((sx - r - 1.0).floor().max(0.0) as usize, ((sx + r + 1.0).ceil().max(0.0) as usize).min(w));
let (y0, y1) = ((sy - r - 1.0).floor().max(0.0) as usize, ((sy + r + 1.0).ceil().max(0.0) as usize).min(h));
for y in y0..y1 {
for x in x0..x1 {
let d = ((x as f32 + 0.5 - sx).powi(2) + (y as f32 + 0.5 - sy).powi(2)).sqrt();
let a = 1.0 - smoothstep(r * hard - 0.5, r + 0.5, d);
let i = y * w + x;
if a > cov[i] {
cov[i] = a;
}
}
}
}
img.px.par_chunks_mut(4).zip(cov.par_iter()).for_each(|(p, &c)| {
let k = c * op;
match style {
1 => p.copy_from_slice(&[col[0] * k, col[1] * k, col[2] * k, k]),
2 => {
for v in p.iter_mut() {
*v *= k;
}
}
_ => over(p, [col[0] * k, col[1] * k, col[2] * k, k]),
}
});
}
+437
View File
@@ -0,0 +1,437 @@
//! Effects that look at other frames: Posterize Time, Echo, Auto Reframe and Warp Stabilizer.
//!
//! Warp Stabilizer is a 2-D (similarity / translation) stabilizer: on first use it analyses the
//! clip — every frame decoded small, Shi–Tomasi features tracked frame to frame with pyramidal
//! Lucas–Kanade and a RANSAC similarity fit (the mask tracker in [`crate::track`]) — and caches
//! the per-frame steps in memory. Rendering smooths the accumulated camera path with a Gaussian
//! (Smoothness) or holds it fixed (No Motion), warps each frame onto the smoothed path and frames
//! the result (crop / auto-scale / synthesise edges). Perspective and Subspace Warp use the
//! similarity model (no per-region warping).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_project::{EffectInstance, TrackMethod};
use rayon::prelude::*;
use super::*;
use crate::track::{Gray, Prepared, track_step};
/// Posterize Time: the clip shows a new frame only at the effect's frame rate.
pub fn posterize_time(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let Some(env) = cx.env else { return };
let rate = fv(e, "rate", cx).max(0.01) as f64;
let t = cx.seconds;
let q = (t * rate + 1e-6).floor() / rate;
let dt = q - t;
if dt.abs() < 1e-6 {
return;
}
if let Some(f) = env.frame(dt, e) {
*img = f;
}
}
/// Echo: composites copies of the clip from earlier (negative Echo Time) or later frames.
pub fn echo(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let Some(env) = cx.env else { return };
let dt = fv(e, "time", cx) as f64;
let n = fv(e, "count", cx).clamp(0.0, 30.0) as usize;
if n == 0 || dt.abs() < 1e-6 {
return;
}
let start = fv(e, "start", cx).clamp(0.0, 1.0);
let decay = fv(e, "decay", cx).max(0.0);
let op = chv(e, "operator");
let mut layers: Vec<(Image, f32)> = vec![(img.clone(), start)];
for k in 1..=n {
if let Some(f) = env.frame(dt * k as f64, e) {
layers.push((f, start * decay.powi(k as i32)));
}
}
*img = combine_echoes(&layers, op);
}
/// Combine echo layers (index 0 = the current frame) with an Echo Operator.
pub(crate) fn combine_echoes(layers: &[(Image, f32)], op: u32) -> Image {
let (w, h) = (layers[0].0.w, layers[0].0.h);
let mut out = Image::new(w, h);
let wsum: f32 = layers.iter().map(|l| l.1).sum::<f32>().max(1e-6);
out.px.par_chunks_mut(4).enumerate().for_each(|(i, o)| {
let px = |l: &(Image, f32)| -> [f32; 4] {
if l.0.w != w || l.0.h != h {
return [0.0; 4];
}
let p = &l.0.px[i * 4..i * 4 + 4];
[p[0] * l.1, p[1] * l.1, p[2] * l.1, p[3] * l.1]
};
let mut acc = match op {
2 => [f32::INFINITY; 4],
3 => [1.0; 4],
_ => [0.0; 4],
};
match op {
4 => {
// composite in back: the current frame in front, older echoes behind
for l in layers.iter().rev() {
over(&mut acc, px(l));
}
}
5 => {
// composite in front: echoes over the current frame
for l in layers {
over(&mut acc, px(l));
}
}
_ => {
for l in layers {
let p = px(l);
for k in 0..4 {
acc[k] = match op {
1 => acc[k].max(p[k]),
2 => acc[k].min(p[k]),
3 => acc[k] * (1.0 - p[k].min(1.0)),
6 => acc[k] + p[k] / wsum,
_ => acc[k] + p[k],
};
}
}
if op == 3 {
acc = acc.map(|v| 1.0 - v);
}
}
}
if op == 0 {
acc[3] = acc[3].min(1.0);
}
o.copy_from_slice(&acc.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }));
});
out
}
// ------------------------------------------------------------------ analysis helpers
fn gray_of(img: &Image) -> Gray {
let px = img.px.par_chunks(4).map(|p| filmcraft_color::linear_to_srgb(luma([p[0], p[1], p[2]]).clamp(0.0, 1.0))).collect();
Gray::new(img.w, img.h, px)
}
/// Saliency centroid (0..1 of the frame): gradient energy weighted, mildly centre-biased.
pub(crate) fn saliency_centroid(img: &Image) -> (f32, f32) {
let g = gray_of(img);
let (w, h) = (g.w, g.h);
let (mut sx, mut sy, mut sw) = (0.0f64, 0.0f64, 0.0f64);
for y in 1..h.saturating_sub(1) {
for x in 1..w.saturating_sub(1) {
let gx = g.px[y * w + x + 1] - g.px[y * w + x - 1];
let gy = g.px[(y + 1) * w + x] - g.px[(y - 1) * w + x];
let (u, v) = (x as f32 / w as f32 - 0.5, y as f32 / h as f32 - 0.5);
let bias = 1.0 - (u * u + v * v) * 0.8;
let e = ((gx * gx + gy * gy) * bias.max(0.1)) as f64;
let e = e * e;
sx += e * x as f64;
sy += e * y as f64;
sw += e;
}
}
if sw <= 1e-12 {
return (0.5, 0.5);
}
(((sx / sw + 0.5) / w as f64) as f32, ((sy / sw + 0.5) / h as f64) as f32)
}
type CentroidCache = Mutex<HashMap<(u64, i64), (f32, f32)>>;
fn centroid_cache() -> &'static CentroidCache {
static C: OnceLock<CentroidCache> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Auto Reframe: keeps the subject (a saliency centroid averaged over a time window set by the
/// Motion Preset) in frame while zooming the clip to fill the sequence (or a chosen aspect).
pub fn auto_reframe(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (lw, lh) = (img.w as f64, img.h as f64);
// the frame the layer must fill, in layer pixels (Motion at its defaults places the layer
// centred in the sequence 1:1)
let target = match (chv(e, "aspect"), cx.env) {
(0, Some(env)) => {
let (sw, sh) = env.sequence_size();
let (src_w, _) = env.source_size();
let k = lw / src_w.max(1) as f64;
(sw as f64 * k, sh as f64 * k)
}
(0, None) => return,
(a, _) => {
let r = [16.0 / 9.0, 9.0 / 16.0, 1.0, 4.0 / 5.0, 16.0 / 9.0][a.min(4) as usize];
if lw / lh > r { (lh * r, lh) } else { (lw, lw / r) }
}
};
let zoom_user = (fv(e, "zoom", cx) as f64 / 100.0).max(1.0);
let z = (target.0 / lw).max(target.1 / lh).max(1.0) * zoom_user;
// window of the source that stays visible
let (vw, vh) = (target.0.min(lw * z) / z, target.1.min(lh * z) / z);
let (half, step) = [(1.0, 0.25), (0.5, 0.125), (0.15, 0.05)][chv(e, "preset").min(2) as usize];
let subject = match cx.env {
Some(env) => {
let key = env.clip_key();
let fps = env.frame_rate().max(1.0);
let mut acc = (0.0f64, 0.0f64, 0.0f64);
let mut k = -half;
while k <= half + 1e-9 {
let t = cx.seconds + k;
if t >= -1e-9 && t <= env.clip_seconds() + 1e-9 {
let id = (t * fps).round() as i64;
let cached = centroid_cache().lock().ok().and_then(|c| c.get(&(key, id)).copied());
let c = cached.or_else(|| {
let f = env.source_frame(id as f64 / fps - cx.seconds, (160.0 / env.source_size().0.max(1) as f32).min(1.0))?;
let c = saliency_centroid(&f);
if let Ok(mut m) = centroid_cache().lock() {
if m.len() > 100_000 {
m.clear();
}
m.insert((key, id), c);
}
Some(c)
});
if let Some((u, v)) = c {
let wgt = 1.0 - (k / (half + step)).abs();
acc = (acc.0 + u as f64 * wgt, acc.1 + v as f64 * wgt, acc.2 + wgt);
}
}
k += step;
}
if acc.2 > 0.0 { (acc.0 / acc.2, acc.1 / acc.2) } else { (0.5, 0.5) }
}
None => {
let (u, v) = saliency_centroid(&img.downsample2());
(u as f64, v as f64)
}
};
let off = offv(e, "offset", cx);
let sx = (subject.0 * lw + off.x).clamp(vw / 2.0, lw - vw / 2.0);
let sy = (subject.1 * lh + off.y).clamp(vh / 2.0, lh - vh / 2.0);
// output(p) = input(subject + (p − centre) / z)
let m = Affine::translate(lw / 2.0, lh / 2.0).then_apply(&Affine::scale(z, z)).then_apply(&Affine::translate(-sx, -sy));
if (z - 1.0).abs() < 1e-9 && (sx - lw / 2.0).abs() < 1e-6 && (sy - lh / 2.0).abs() < 1e-6 {
return;
}
affine_warp(img, &m);
}
// ------------------------------------------------------------------ Warp Stabilizer
/// A clip's analysed camera motion: per-frame steps (frame k → k+1) in analysis pixels.
#[derive(Clone, Debug)]
pub struct StabilizerPath {
pub w: usize,
pub h: usize,
pub steps: Vec<Affine>,
pub fps: f64,
}
type PathCache = Mutex<HashMap<u64, Arc<StabilizerPath>>>;
fn path_cache() -> &'static PathCache {
static C: OnceLock<PathCache> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Forget every cached stabilizer analysis (e.g. after the media changed).
pub fn clear_stabilizer_cache() {
if let Ok(mut c) = path_cache().lock() {
c.clear();
}
}
/// Analyse (or fetch the cached analysis of) the clip `env` describes.
pub fn stabilizer_path(env: &dyn FxEnv, now: f64, method: TrackMethod, detailed: bool) -> Option<Arc<StabilizerPath>> {
let key = env.clip_key() ^ (method as u64).wrapping_mul(0x9E37) ^ (detailed as u64).wrapping_mul(0x51_7CC1);
if let Some(p) = path_cache().lock().ok().and_then(|c| c.get(&key).cloned()) {
return Some(p);
}
let fps = env.frame_rate().max(1.0);
let n = ((env.clip_seconds() * fps).round() as usize).clamp(1, 20_000);
let src_w = env.source_size().0.max(1) as f32;
let target_w = if detailed { 960.0 } else { 480.0 };
let scale = (target_w / src_w).min(1.0);
let frames: Vec<Option<Prepared>> = (0..n)
.into_par_iter()
.map(|k| {
if filmcraft_media::cancel::cancelled() {
return None;
}
env.source_frame(k as f64 / fps - now, scale).map(|f| Prepared::new(gray_of(&f)))
})
.collect();
if filmcraft_media::cancel::cancelled() {
return None;
}
let (w, h) = frames.iter().flatten().next().map(|p| (p.base().w, p.base().h))?;
let m = 8.0;
let region = [Vec2::new(m, m), Vec2::new(w as f64 - m, m), Vec2::new(w as f64 - m, h as f64 - m), Vec2::new(m, h as f64 - m)];
let steps: Vec<Affine> = (0..n.saturating_sub(1))
.into_par_iter()
.map(|k| match (&frames[k], &frames[k + 1]) {
(Some(a), Some(b)) if a.base().w == b.base().w => track_step(a, b, &region, method).map(|s| s.transform).unwrap_or(Affine::IDENTITY),
_ => Affine::IDENTITY,
})
.collect();
let path = Arc::new(StabilizerPath { w, h, steps, fps });
if let Ok(mut c) = path_cache().lock() {
if c.len() > 64 {
c.clear();
}
c.insert(key, path.clone());
}
Some(path)
}
/// Similarity parameters (tx, ty, angle, log-scale) of an affine.
fn decompose(m: &Affine) -> [f64; 4] {
let s = (m.a * m.a + m.b * m.b).sqrt().max(1e-9);
[m.e, m.f, m.b.atan2(m.a), s.ln()]
}
fn compose(p: [f64; 4]) -> Affine {
let s = p[3].exp();
let (sn, cs) = p[2].sin_cos();
Affine { a: s * cs, b: s * sn, c: -s * sn, d: s * cs, e: p[0], f: p[1] }
}
/// Per-frame correction transforms (current frame px → stabilised px, analysis pixels).
pub(crate) fn corrections(path: &StabilizerPath, smooth_frames: f64, no_motion: bool, preserve_scale: bool) -> Vec<Affine> {
let n = path.steps.len() + 1;
// cumulative: frame 0 → frame k, about the frame centre for stable angle/scale decomposition
let (cxp, cyp) = (path.w as f64 / 2.0, path.h as f64 / 2.0);
let to_c = Affine::translate(-cxp, -cyp);
let from_c = Affine::translate(cxp, cyp);
let mut cum = vec![Affine::IDENTITY; n];
for k in 1..n {
cum[k] = path.steps[k - 1].then_apply(&cum[k - 1]);
}
let params: Vec<[f64; 4]> = cum.iter().map(|c| decompose(&to_c.then_apply(c).then_apply(&from_c))).collect();
// unwrap angles
let mut params = params;
for k in 1..n {
let d = params[k][2] - params[k - 1][2];
let wrap = (d / std::f64::consts::TAU).round() * std::f64::consts::TAU;
params[k][2] -= wrap;
}
let smoothed: Vec<[f64; 4]> = if no_motion {
let mean = params.iter().fold([0.0; 4], |a, p| [a[0] + p[0], a[1] + p[1], a[2] + p[2], a[3] + p[3]]).map(|v| v / n as f64);
vec![mean; n]
} else {
let sigma = smooth_frames.max(0.01);
let r = (sigma * 3.0).ceil() as isize;
(0..n as isize)
.map(|k| {
let mut acc = [0.0; 4];
let mut ws = 0.0;
for j in -r..=r {
let i = (k + j).clamp(0, n as isize - 1) as usize;
let w = (-(j as f64).powi(2) / (2.0 * sigma * sigma)).exp();
for c in 0..4 {
acc[c] += params[i][c] * w;
}
ws += w;
}
acc.map(|v| v / ws)
})
.collect()
};
(0..n)
.map(|k| {
let mut sp = smoothed[k];
if preserve_scale {
sp[3] = params[k][3];
}
// in centred coordinates: smoothed ∘ raw⁻¹
let raw = compose(params[k]);
let sm = compose(sp);
let corr = raw.inverse().map(|ri| sm.then_apply(&ri)).unwrap_or(Affine::IDENTITY);
from_c.then_apply(&corr).then_apply(&to_c)
})
.collect()
}
/// Smallest zoom about the centre (≥ 1) that hides the borders a correction exposes.
pub(crate) fn cover_scale(corr: &Affine, w: f64, h: f64, max: f64) -> f64 {
let Some(inv) = corr.inverse() else { return 1.0 };
let (cx, cy) = (w / 2.0, h / 2.0);
let covered = |z: f64| {
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().all(|&(x, y)| {
let q = inv.apply(Vec2::new(cx + (x - cx) / z, cy + (y - cy) / z));
q.x >= -0.5 && q.y >= -0.5 && q.x <= w + 0.5 && q.y <= h + 0.5
})
};
if covered(1.0) {
return 1.0;
}
let (mut lo, mut hi) = (1.0, max.max(1.0));
if !covered(hi) {
return hi;
}
for _ in 0..30 {
let mid = (lo + hi) / 2.0;
if covered(mid) {
hi = mid;
} else {
lo = mid;
}
}
hi
}
/// Warp Stabilizer: see the module docs.
pub fn warp_stabilizer(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let Some(env) = cx.env else { return };
let method = if chv(e, "method") == 0 { TrackMethod::Position } else { TrackMethod::PositionScaleRotation };
let Some(path) = stabilizer_path(env, cx.seconds, method, bv(e, "detailed")) else { return };
let n = path.steps.len() + 1;
let k = ((cx.seconds * path.fps).round().max(0.0) as usize).min(n - 1);
let smooth = fv(e, "smoothness", cx) as f64 / 100.0 * path.fps;
let crop_less = fv(e, "crop_less", cx) as f64 / 100.0;
let corr = corrections(&path, smooth * (0.5 + crop_less), chv(e, "result") == 1, bv(e, "preserve_scale"));
let framing = chv(e, "framing");
let (aw, ah) = (path.w as f64, path.h as f64);
let max_scale = (fv(e, "max_scale", cx) as f64 / 100.0).max(1.0);
let safe = 1.0 + fv(e, "action_safe", cx) as f64 / 100.0;
let extra = fv(e, "additional_scale", cx) as f64 / 100.0;
let zoom = match framing {
2 => corr.iter().map(|c| cover_scale(c, aw, ah, max_scale)).fold(1.0, f64::max) * safe,
_ => 1.0,
} * extra;
// analysis px → layer px
let s = img.w as f64 / aw;
let to_layer = Affine::scale(s, s);
let from_layer = Affine::scale(1.0 / s, 1.0 / s);
let (lw, lh) = (img.w as f64, img.h as f64);
let z = Affine::translate(lw / 2.0, lh / 2.0).then_apply(&Affine::scale(zoom, zoom)).then_apply(&Affine::translate(-lw / 2.0, -lh / 2.0));
let m = z.then_apply(&to_layer).then_apply(&corr[k]).then_apply(&from_layer);
let src = img.clone();
affine_warp(img, &m);
match framing {
1 => {
// crop to the area every frame covers
let worst = corr.iter().map(|c| cover_scale(c, aw, ah, 4.0)).fold(1.0, f64::max);
let (mx, my) = (lw * (1.0 - 1.0 / worst) / 2.0, lh * (1.0 - 1.0 / worst) / 2.0);
crate::effects::crop(img, (mx / lw) as f32, (my / lh) as f32, (mx / lw) as f32, (my / lh) as f32, 0.0);
}
3 => {
// synthesise edges: fill uncovered pixels from the clamped source
if let Some(inv) = m.inverse() {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = &mut row[x * 4..x * 4 + 4];
if p[3] < 0.999 {
let q = inv.apply(Vec2::new(x as f64 + 0.5, y as f64 + 0.5));
let fill = src.sample_bilinear_clamped(q.x as f32, q.y as f32);
let mut b = fill;
over(&mut b, [p[0], p[1], p[2], p[3]]);
p.copy_from_slice(&b);
}
}
});
}
}
_ => {}
}
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
//! Text burn-ins: Simple Text and Metadata & Timecode Burn-in.
use filmcraft_project::EffectInstance;
use filmcraft_text::{Align, ParagraphStyle, TextStyle, layout, render};
use super::*;
use crate::graphics::paint_mask;
/// Draw `text` with its first baseline's cap height centred on `pos` (layer pixels), aligned
/// left/centre/right on `pos.x`, over an optional black box.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, align: u32, color: [f32; 4], box_alpha: f32) {
if text.is_empty() || !pos.x.is_finite() || !pos.y.is_finite() {
return;
}
let al = match align {
0 => Align::Left,
2 => Align::Right,
_ => Align::Center,
};
let st = TextStyle { family: family.into(), style: "Regular".into(), size: px, ..Default::default() };
let l = layout(text, &st, &ParagraphStyle { align: al, ..Default::default() });
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(family, "Regular").face).metrics(px);
let (bx, by) = (pos.x as f32, pos.y as f32 + vm.cap_height / 2.0);
let b = l.bounds;
if box_alpha > 0.0 {
let pad = px * 0.25;
let boxp = filmcraft_text::Path::rect(bx + b[0] - pad, by + b[1] - pad, bx + b[2] + pad, by + b[3] + pad);
if let Some(bb) = boxp.bounds() {
let (x0, y0) = (bb.0.floor() as i32, bb.1.floor() as i32);
let m = filmcraft_text::raster::fill(
&boxp,
(bb.2.ceil() as i32 - x0).max(1) as usize,
(bb.3.ceil() as i32 - y0).max(1) as usize,
-x0 as f32,
-y0 as f32,
);
paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha.clamp(0.0, 1.0));
}
}
let (m, x0, y0) = render::rasterize(&l, &render::at(bx, by.round()));
paint_mask(img, &m, x0, y0, crate::graphics::premul_linear(color), 1.0);
}
/// Simple Text: a single block of text burned into the clip.
pub fn simple_text(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let text = tv(e, "text");
let pos = pv(e, "position", cx, img);
let size = (fv(e, "size", cx) * cx.px_scale).max(2.0);
let family = ["Inter", "JetBrains Mono"][chv(e, "font").min(1) as usize];
let color = cv(e, "color", cx);
let bg = fv(e, "bg_opacity", cx) / 100.0;
draw_text(img, &text, family, pos, size, chv(e, "alignment"), color, bg);
}
/// The text a Metadata & Timecode Burn-in shows.
pub(crate) fn burnin_text(e: &EffectInstance, cx: &FxCtx) -> String {
let body = match chv(e, "source") {
0 => cx.timecode.to_string(),
1 => cx.env.map_or_else(|| cx.timecode.to_string(), |env| env.media_timecode()),
2 => cx.clip_name.to_string(),
3 => cx.env.map_or_else(|| cx.clip_name.to_string(), |env| env.file_name()),
4 => {
let fps = cx.env.map_or(30.0, |env| env.frame_rate());
format!("{}", (cx.seconds * fps).round() as i64)
}
_ => cx.env.map(|env| env.sequence_name()).unwrap_or_default(),
};
let prefix = tv(e, "prefix");
if prefix.is_empty() { body } else { format!("{prefix} {body}") }
}
/// Metadata & Timecode Burn-in: sequence / media timecode, names or frame count in a box.
pub fn metadata_burnin(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let text = burnin_text(e, cx);
let size = (fv(e, "size", cx) / 100.0 * img.h as f32).max(6.0);
let (w, h) = (img.w as f64, img.h as f64);
let margin = (size as f64) * 1.2;
let (pos, align) = match chv(e, "alignment") {
0 => (Vec2::new(w / 2.0, h - margin), 1),
1 => (Vec2::new(margin, h - margin), 0),
2 => (Vec2::new(w - margin, h - margin), 2),
3 => (Vec2::new(w / 2.0, margin), 1),
4 => (Vec2::new(margin, margin), 0),
5 => (Vec2::new(w - margin, margin), 2),
_ => (pv(e, "position", cx, img), 1),
};
let family = if matches!(chv(e, "source"), 0 | 1 | 4) { "JetBrains Mono" } else { "Inter" };
draw_text(img, &text, family, pos, size, align, cv(e, "color", cx), fv(e, "opacity", cx) / 100.0);
}
@@ -312,6 +312,22 @@ pub(crate) fn param_row(
(ParamKind::Path, _) => {
crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions);
}
(ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => {
// edited in a buffer; committed (one undo step) when the field loses focus
let mut buf = vui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| s.clone());
let r = vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0));
app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label);
if r.lost_focus() {
if buf != *s {
set = Some(json!(buf));
}
vui.data_mut(|d| d.remove::<String>(id));
} else if r.has_focus() {
vui.data_mut(|d| d.insert_temp(id, buf));
} else {
vui.data_mut(|d| d.remove::<String>(id));
}
}
_ => {
vui.label(param_text(&app.session.project, pd.id, &value));
}
+6 -2
View File
@@ -21,7 +21,7 @@ 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 = ["Presets", "Lumetri Presets", "Audio Effects", "Audio Transitions", "Video Effects", "Video Transitions", "Legacy"];
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| {
@@ -33,11 +33,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
continue;
}
let items: Vec<_> = defs
let mut items: Vec<_> = defs
.iter()
.filter(|d| d.category.first() == Some(&top))
.filter(|d| filter.is_empty() || d.name.to_ascii_lowercase().contains(&filter))
.collect();
// Premiere lists effect folders and effects alphabetically (obsolete ones last)
if top == "Video Effects" || top == "Legacy" {
items.sort_by_key(|d| (d.category.get(1) == Some(&"Obsolete"), d.category.get(1).copied().unwrap_or(""), d.name.to_ascii_lowercase()));
}
if !filter.is_empty() && items.is_empty() {
continue;
}
+1 -1
View File
@@ -57,7 +57,7 @@ and `filmcraft-cli`.
| `speech` | L2 | speech-to-text: `Transcriber` trait, Whisper model catalogue + verified downloader (feature `download`), pure-Rust Whisper inference on candle with word timestamps (feature `whisper`), speaker labelling ([transcripts.md](transcripts.md)) |
| `codecs` | L2 | container + codec hub: MP4/MOV and MKV sources, GOP-aware seeking, decoder registry, audio decoding |
| `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) |
| `render` | L3 | sequence evaluation, CPU compositor, video effects, transitions, audio mix |
| `render` | L3 | sequence evaluation, CPU compositor, video effects (`effects`, `vfx`; effects needing other frames or tracks read them through `vfx::FxEnv`), transitions, audio mix |
| `gpu` | L3 | wgpu compositor (WGSL) |
| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only |
| `export` | L3 | render → encode → mux pipeline, progress/cancel |
+5 -1
View File
@@ -103,7 +103,11 @@ Each codec README has the full fixture matrix and the measured results.
with `crates/golden/goldens/<scene>.png` by `filmcraft_testkit::golden`:
**PSNR ≥ 45 dB, max abs ≤ 12, 99th-percentile per-pixel max channel difference ≤ 2** (8-bit
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
`<workspace>/target/golden-failures/`. Six `vfx_*` scenes cover the M5.11 effects (Corner Pin over Turbulent
Displace, Ultra Key, Wonder Glow + Glint, Spacer + Stroke + Long Shadow over Gradient, VR Rotate Sphere,
Channel Mix + Color Emboss + Rounded Crop); per-effect unit tests live in `crates/render/src/vfx/tests.rs`
(identity at neutral parameters, determinism, range, a known value each, temporal/track effects through
the compositor). `scenes_are_distinct` guards against blank or duplicate
scenes.
- **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).