Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ pub struct Scene {
/// (world) Keep this scene visible after its time window ends.
#[serde(default)]
pub persist: bool,
/// Post-processing effects applied to the full frame buffer after Skia renders.
/// Effects are additive and applied in declaration order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<PostEffect>,
/// Post-resolution background (populated by include.rs, ignored by serde).
#[serde(skip)]
#[schemars(skip)]
Expand Down Expand Up @@ -363,6 +367,91 @@ fn default_camera_zoom() -> f32 {
1.0
}

/// Direction for progressive blur.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BlurDirection {
Top,
Bottom,
}

impl Default for BlurDirection {
fn default() -> Self {
BlurDirection::Bottom
}
}

/// A post-processing effect applied to the full frame buffer after Skia renders.
/// Effects are pure Rust, deterministic, and applied in declaration order.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PostEffect {
/// Film grain noise overlaid on every pixel.
Grain {
/// Noise strength clamped to 0..1. Default: 0.15.
#[serde(default = "default_grain_intensity")]
intensity: f32,
/// Base seed for the noise hash. Default: 42.
#[serde(default = "default_grain_seed")]
seed: u64,
/// When true, the pattern changes per frame (`seed ^ frame_index`). Default: true.
#[serde(default = "default_true")]
animated: bool,
},
/// Darken the frame edges towards the corners.
Vignette {
/// Darkness strength clamped to 0..1. Default: 0.5.
#[serde(default = "default_vignette_intensity")]
intensity: f32,
/// Fraction of the half-diagonal where darkening starts. Default: 0.75.
#[serde(default = "default_vignette_radius")]
radius: f32,
},
/// Reduce spatial resolution by averaging square pixel blocks.
Pixelate {
/// Block size in pixels, clamped to 1..=256. Default: 8.
#[serde(default = "default_pixelate_size")]
size: u32,
},
/// Blur that grows from zero at `start` to `max_radius` at the frame edge.
ProgressiveBlur {
/// Which edge gets the maximum blur. Default: bottom.
#[serde(default)]
direction: BlurDirection,
/// Fraction of the frame height where blur begins (0.0..1.0). Default: 0.5.
#[serde(default = "default_blur_start")]
start: f32,
/// Maximum box-blur radius in pixels at the far edge. Default: 12.0.
#[serde(default = "default_blur_max_radius")]
max_radius: f32,
},
}

fn default_grain_intensity() -> f32 {
0.15
}
fn default_grain_seed() -> u64 {
42
}
fn default_true() -> bool {
true
}
fn default_vignette_intensity() -> f32 {
0.5
}
fn default_vignette_radius() -> f32 {
0.75
}
fn default_pixelate_size() -> u32 {
8
}
fn default_blur_start() -> f32 {
0.5
}
fn default_blur_max_radius() -> f32 {
12.0
}

/// Scene-level flex layout configuration
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SceneLayout {
Expand Down
2 changes: 2 additions & 0 deletions crates/rustmotion-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub enum HtmlError {
MissingDuration,
#[error("background attribute contains invalid JSON: {0}")]
InvalidBackgroundJson(String),
#[error("effects attribute contains invalid JSON: {0}")]
InvalidEffectsJson(String),
#[error("anim attribute contains invalid JSON: {0}")]
InvalidAnimJson(String),
#[error("invalid anim DSL: {0}")]
Expand Down
79 changes: 79 additions & 0 deletions crates/rustmotion-html/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ use crate::parse_background_attr;
use crate::style::coerce_value;
use crate::HtmlError;

/// Parse an `effects` attribute value: must be a JSON array.
fn parse_effects_attr(raw: &str) -> Result<Value, HtmlError> {
let trimmed = raw.trim();
serde_json::from_str(trimmed).map_err(|e| HtmlError::InvalidEffectsJson(e.to_string()))
}

/// Map a `<scene>` element to a scene JSON object. Defaults to a centered flex
/// layout (overridable via `align`/`justify`/`direction`/`gap`/`padding` attrs).
pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
Expand Down Expand Up @@ -42,6 +48,18 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
obj.insert("background".into(), parse_background_attr(&bg)?);
}

// effects: JSON array of PostEffect objects
if let Some(fx) = get("effects") {
let parsed = parse_effects_attr(&fx)?;
// Validate that it is an array (not just any JSON value)
if !parsed.is_array() {
return Err(HtmlError::InvalidEffectsJson(
"effects must be a JSON array".into(),
));
}
obj.insert("effects".into(), parsed);
}

// transition: type is required; duration and easing are optional extras
let has_transition_params =
get("transition-duration").is_some() || get("transition-easing").is_some();
Expand Down Expand Up @@ -127,6 +145,67 @@ mod tests {
);
}

// --- effects ---

#[test]
fn scene_effects_json_array_transpiled() {
let dom = crate::parse_fragment_dom(
r##"<scene duration="2" effects='[{"type":"grain","intensity":0.2}]'></scene>"##,
);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert_eq!(v["effects"][0]["type"], json!("grain"));
assert_eq!(v["effects"][0]["intensity"], json!(0.2));
}

#[test]
fn scene_effects_invalid_json_is_error() {
let dom =
crate::parse_fragment_dom(r#"<scene duration="2" effects="{not valid json}"></scene>"#);
let el = crate::find_element(&dom.document, "scene").unwrap();
let err = scene_to_value(&el).unwrap_err();
assert!(
matches!(err, crate::HtmlError::InvalidEffectsJson(_)),
"expected InvalidEffectsJson, got: {err:?}"
);
}

#[test]
fn scene_effects_non_array_is_error() {
let dom = crate::parse_fragment_dom(
r##"<scene duration="2" effects='{"type":"grain"}'></scene>"##,
);
let el = crate::find_element(&dom.document, "scene").unwrap();
let err = scene_to_value(&el).unwrap_err();
assert!(
matches!(err, crate::HtmlError::InvalidEffectsJson(_)),
"expected InvalidEffectsJson (non-array), got: {err:?}"
);
}

#[test]
fn scene_without_effects_has_no_effects_key() {
let dom = crate::parse_fragment_dom(r#"<scene duration="2"></scene>"#);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert!(
v.get("effects").is_none(),
"effects key must be absent when not declared"
);
}

#[test]
fn scene_effects_multiple_entries() {
let dom = crate::parse_fragment_dom(
r##"<scene duration="2" effects='[{"type":"vignette","intensity":0.6},{"type":"pixelate","size":4}]'></scene>"##,
);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert_eq!(v["effects"].as_array().unwrap().len(), 2);
assert_eq!(v["effects"][0]["type"], json!("vignette"));
assert_eq!(v["effects"][1]["type"], json!("pixelate"));
}

// --- transition with duration and easing ---

#[test]
Expand Down
47 changes: 39 additions & 8 deletions crates/rustmotion/src/encode/video/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ pub fn render_frame_task_scaled(
scale_factor: f32,
) -> Result<Vec<u8>> {
use crate::engine::render::{
render_scene_bg_scaled, render_scene_fg_scaled, render_scene_frame,
render_scene_frame_scaled, render_scene_frame_scaled_with_prev_bg,
post_effects::apply_post_effects, render_scene_bg_scaled, render_scene_fg_scaled,
render_scene_frame, render_scene_frame_scaled, render_scene_frame_scaled_with_prev_bg,
};

match task {
Expand All @@ -97,14 +97,24 @@ pub fn render_frame_task_scaled(
} else {
None
};
render_scene_frame_scaled_with_prev_bg(
let mut pixels = render_scene_frame_scaled_with_prev_bg(
config,
scene,
*frame_in_scene,
*scene_total_frames,
scale_factor,
prev_bg,
)
)?;
let scaled_w = (config.width as f32 * scale_factor) as u32;
let scaled_h = (config.height as f32 * scale_factor) as u32;
apply_post_effects(
&mut pixels,
scaled_w,
scaled_h,
&scene.effects,
*frame_in_scene,
);
Ok(pixels)
}
FrameTask::SlideTransition {
view_idx,
Expand Down Expand Up @@ -158,7 +168,8 @@ pub fn render_frame_task_scaled(
*scene_b_total_frames,
scale_factor,
)?;
return Ok(camera_pan_transition(
// CameraPan composites two scenes; apply scene_b effects to the composited result.
let mut composited = camera_pan_transition(
&bg,
&fg_a,
&fg_b,
Expand All @@ -168,7 +179,15 @@ pub fn render_frame_task_scaled(
dx * scale_factor,
dy * scale_factor,
easing,
));
);
apply_post_effects(
&mut composited,
scaled_w,
scaled_h,
&scenes[*scene_b_idx].effects,
*frame_in_transition,
);
return Ok(composited);
}

let (frame_a, frame_b) = if scale_factor == 1.0 {
Expand Down Expand Up @@ -203,14 +222,26 @@ pub fn render_frame_task_scaled(
(a, b)
};

Ok(apply_transition(
// For slide transitions, apply effects of scene_b to the composited result.
// Rationale: the transition is the "entry" of scene_b; its post-effects
// (e.g. vignette) should appear on the blended frames to avoid a
// jarring pop when the transition ends and Normal frames begin.
let mut composited = apply_transition(
&frame_a,
&frame_b,
scaled_w,
scaled_h,
progress,
transition_type,
))
);
apply_post_effects(
&mut composited,
scaled_w,
scaled_h,
&scenes[*scene_b_idx].effects,
*frame_in_transition,
);
Ok(composited)
}
FrameTask::WorldFrame {
view_idx,
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/src/engine/render/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod background;
mod canvas_guard;
pub mod post_effects;
mod scene;

pub(crate) use canvas_guard::CanvasGuard;
Expand Down
Loading
Loading