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
150 changes: 143 additions & 7 deletions crates/rustmotion-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub enum HtmlError {
NoScenes,
#[error("<scene> requires a duration attribute")]
MissingDuration,
#[error("background attribute contains invalid JSON: {0}")]
InvalidBackgroundJson(String),
#[error("transition-duration and transition-easing require a transition attribute")]
TransitionParamsWithoutTransition,
#[error("<font> requires both family and path (or src) attributes")]
MissingFontAttributes,
}

/// Transpile an HTML-dialect document into the scenario `serde_json::Value` that
Expand All @@ -43,20 +49,72 @@ pub fn html_to_scenario_value(html: &str) -> Result<Value, HtmlError> {
video.insert("fps".into(), style::coerce_value(&fps));
}
if let Some(bg) = get("background") {
video.insert("background".into(), Value::from(bg));
video.insert("background".into(), parse_background_attr(&bg)?);
}

let mut scenes = Vec::new();
for child in root.children.borrow().iter() {
if tag_name(child).as_deref() == Some("scene") {
scenes.push(scene::scene_to_value(child)?);
}
}
let mut fonts = Vec::new();
collect_scenes_and_fonts(&root, &mut scenes, &mut fonts)?;
if scenes.is_empty() {
return Err(HtmlError::NoScenes);
}

Ok(serde_json::json!({ "video": Value::Object(video), "scenes": scenes }))
let mut scenario = Map::new();
scenario.insert("video".into(), Value::Object(video));
scenario.insert("scenes".into(), Value::Array(scenes));
if !fonts.is_empty() {
scenario.insert("fonts".into(), Value::Array(fonts));
}
Ok(Value::Object(scenario))
}

/// Parse a `background` attribute value: if it starts with `{` or `[`, treat it as
/// JSON and parse it; otherwise return it as a plain string value.
pub(crate) fn parse_background_attr(raw: &str) -> Result<Value, HtmlError> {
let trimmed = raw.trim();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
serde_json::from_str(trimmed).map_err(|e| HtmlError::InvalidBackgroundJson(e.to_string()))
} else {
Ok(Value::from(raw))
}
}

/// Map a `<font>` element to a `{"family": ..., "path": ...}` JSON object.
fn font_to_value(handle: &Handle) -> Result<Value, HtmlError> {
let attrs = element_attrs(handle);
let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());

let family = get("family").ok_or(HtmlError::MissingFontAttributes)?;
// Accept both `path` and `src` attribute names.
let path = get("path")
.or_else(|| get("src"))
.ok_or(HtmlError::MissingFontAttributes)?;

Ok(serde_json::json!({ "family": family, "path": path }))
}

/// Walk the immediate children of `parent`, collecting `<scene>` and `<font>`
/// elements. Because html5ever's HTML5 parser treats `<font>` as a formatting
/// element and nests subsequent siblings inside it, we recurse into `<font>`
/// children so that `<scene>` elements placed after `<font>` declarations are
/// still found at any depth.
fn collect_scenes_and_fonts(
parent: &Handle,
scenes: &mut Vec<Value>,
fonts: &mut Vec<Value>,
) -> Result<(), HtmlError> {
for child in parent.children.borrow().iter() {
match tag_name(child).as_deref() {
Some("scene") => scenes.push(scene::scene_to_value(child)?),
Some("font") => {
fonts.push(font_to_value(child)?);
// Recurse: html5ever may nest siblings inside the <font> element.
collect_scenes_and_fonts(child, scenes, fonts)?;
}
_ => {}
}
}
Ok(())
}

/// Parse an HTML fragment into an RcDom (browserless; html5ever).
Expand Down Expand Up @@ -318,4 +376,82 @@ mod lib_tests {
fn missing_root_is_an_error() {
assert!(crate::html_to_scenario_value("<div>no root</div>").is_err());
}

// --- font tests ---

#[test]
fn fonts_are_collected_from_font_elements() {
let html = r##"<rustmotion width="1920" height="1080">
<font family="Inter" path="fonts/Inter.ttf">
<font family="JetBrainsMono" src="fonts/JetBrainsMono.ttf">
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let v = crate::html_to_scenario_value(html).unwrap();
let fonts = &v["fonts"];
assert_eq!(fonts[0]["family"], json!("Inter"));
assert_eq!(fonts[0]["path"], json!("fonts/Inter.ttf"));
assert_eq!(fonts[1]["family"], json!("JetBrainsMono"));
assert_eq!(fonts[1]["path"], json!("fonts/JetBrainsMono.ttf"));
}

#[test]
fn font_without_family_is_error() {
let html = r##"<rustmotion width="1920" height="1080">
<font path="fonts/Inter.ttf">
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let err = crate::html_to_scenario_value(html).unwrap_err();
assert!(
matches!(err, crate::HtmlError::MissingFontAttributes),
"expected MissingFontAttributes, got: {err:?}"
);
}

#[test]
fn font_without_path_is_error() {
let html = r##"<rustmotion width="1920" height="1080">
<font family="Inter">
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let err = crate::html_to_scenario_value(html).unwrap_err();
assert!(
matches!(err, crate::HtmlError::MissingFontAttributes),
"expected MissingFontAttributes, got: {err:?}"
);
}

#[test]
fn no_fonts_means_no_fonts_key() {
let html = r##"<rustmotion width="1920" height="1080">
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let v = crate::html_to_scenario_value(html).unwrap();
assert!(
v.get("fonts").is_none(),
"fonts key should be absent when no fonts declared"
);
}

// --- root-level background JSON ---

#[test]
fn root_background_json_object_is_parsed() {
let html = r##"<rustmotion width="1920" height="1080" background='{"gradient":"linear","colors":["#0f172a","#1e3a5f"]}'>
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let v = crate::html_to_scenario_value(html).unwrap();
assert_eq!(v["video"]["background"]["gradient"], json!("linear"));
}

#[test]
fn root_background_invalid_json_is_error() {
let html = r##"<rustmotion width="1920" height="1080" background="{bad json}">
<scene duration="2"><h1>hi</h1></scene>
</rustmotion>"##;
let err = crate::html_to_scenario_value(html).unwrap_err();
assert!(
matches!(err, crate::HtmlError::InvalidBackgroundJson(_)),
"expected InvalidBackgroundJson, got: {err:?}"
);
}
}
97 changes: 96 additions & 1 deletion crates/rustmotion-html/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use serde_json::{Map, Value};

use crate::element::children_to_values;
use crate::element_attrs;
use crate::parse_background_attr;
use crate::style::coerce_value;
use crate::HtmlError;

Expand Down Expand Up @@ -36,8 +37,26 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
}
obj.insert("layout".into(), Value::Object(layout));

// background: string or inline JSON object/array
if let Some(bg) = get("background") {
obj.insert("background".into(), parse_background_attr(&bg)?);
}

// transition: type is required; duration and easing are optional extras
let has_transition_params =
get("transition-duration").is_some() || get("transition-easing").is_some();
if let Some(t) = get("transition") {
obj.insert("transition".into(), serde_json::json!({ "type": t }));
let mut tr = Map::new();
tr.insert("type".into(), Value::from(t));
if let Some(dur) = get("transition-duration") {
tr.insert("duration".into(), coerce_value(&dur));
}
if let Some(eas) = get("transition-easing") {
tr.insert("easing".into(), Value::from(eas));
}
obj.insert("transition".into(), Value::Object(tr));
} else if has_transition_params {
return Err(HtmlError::TransitionParamsWithoutTransition);
}

obj.insert("children".into(), Value::Array(children_to_values(handle)));
Expand Down Expand Up @@ -69,4 +88,80 @@ mod tests {
let v = scene_to_value(&el).unwrap();
assert_eq!(v["transition"]["type"], json!("fade"));
}

// --- background ---

#[test]
fn scene_background_string_color() {
let dom =
crate::parse_fragment_dom(r##"<scene duration="2" background="#1e293b"></scene>"##);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert_eq!(v["background"], json!("#1e293b"));
}

#[test]
fn scene_background_json_object() {
let dom = crate::parse_fragment_dom(
r##"<scene duration="2" background='{"preset":"halo","halo":{"zones":[{"color":"#7c3aed","x":0.5,"y":0.5,"radius":0.4}]},"speed":0}'></scene>"##,
);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert_eq!(v["background"]["preset"], json!("halo"));
assert_eq!(
v["background"]["halo"]["zones"][0]["color"],
json!("#7c3aed")
);
}

#[test]
fn scene_background_invalid_json_is_error() {
let dom = crate::parse_fragment_dom(
r#"<scene duration="2" background="{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::InvalidBackgroundJson(_)),
"expected InvalidBackgroundJson, got: {err:?}"
);
}

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

#[test]
fn scene_transition_full_params() {
let dom = crate::parse_fragment_dom(
r#"<scene duration="3" transition="fade" transition-duration="0.8" transition-easing="ease_in_out"></scene>"#,
);
let el = crate::find_element(&dom.document, "scene").unwrap();
let v = scene_to_value(&el).unwrap();
assert_eq!(v["transition"]["type"], json!("fade"));
assert_eq!(v["transition"]["duration"], json!(0.8));
assert_eq!(v["transition"]["easing"], json!("ease_in_out"));
}

#[test]
fn scene_transition_duration_without_transition_is_error() {
let dom =
crate::parse_fragment_dom(r#"<scene duration="3" transition-duration="0.8"></scene>"#);
let el = crate::find_element(&dom.document, "scene").unwrap();
let err = scene_to_value(&el).unwrap_err();
assert!(
matches!(err, crate::HtmlError::TransitionParamsWithoutTransition),
"expected TransitionParamsWithoutTransition, got: {err:?}"
);
}

#[test]
fn scene_transition_easing_without_transition_is_error() {
let dom =
crate::parse_fragment_dom(r#"<scene duration="3" transition-easing="linear"></scene>"#);
let el = crate::find_element(&dom.document, "scene").unwrap();
let err = scene_to_value(&el).unwrap_err();
assert!(
matches!(err, crate::HtmlError::TransitionParamsWithoutTransition),
"expected TransitionParamsWithoutTransition, got: {err:?}"
);
}
}
39 changes: 39 additions & 0 deletions examples/html/showcase.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<rustmotion width="1920" height="1080" fps="30" background="#0f172a">
<!-- Font declarations: <font family="..." path="..."> or <font family="..." src="..."> -->
<font family="Inter" path="fonts/Inter-Regular.ttf">
<font family="JetBrainsMono" path="fonts/JetBrainsMono-Regular.ttf">

<!-- Scene 1: plain color background -->
<scene duration="3" background="#0f172a" transition="fade" transition-duration="0.6" transition-easing="ease_in_out">
<div style="flex-direction:column; align-items:center; gap:24">
<h1 style="font-size:96; color:#ffffff; font-family:Inter; text-align:center">
HTML Dialect
</h1>
<p style="font-size:40; color:#94a3b8; text-align:center">
Backgrounds · Transitions · Fonts
</p>
</div>
</scene>

<!-- Scene 2: animated halo background (inline JSON object) -->
<scene duration="4" background='{"preset":"halo","halo":{"zones":[{"color":"#7c3aed","x":0.3,"y":0.4,"radius":0.45},{"color":"#0ea5e9","x":0.7,"y":0.6,"radius":0.35}]},"speed":0}' transition="wipe_left" transition-duration="0.5">
<div style="flex-direction:column; align-items:center; gap:32">
<h2 style="font-size:72; color:#ffffff; font-family:Inter; text-align:center">
Animated Background
</h2>
<p style="font-size:36; color:#e2e8f0; text-align:center">
Inline JSON in the background attribute
</p>
</div>
</scene>

<!-- Scene 3: grid_dots animated background -->
<scene duration="3" background='{"preset":"grid_dots","grid_dots":{"color":"#FFFFFF20","element_size":4,"spacing":60},"speed":30,"direction":"up"}' transition="dissolve" transition-duration="0.4" transition-easing="ease_out">
<div style="flex-direction:column; align-items:center; gap:24">
<h2 style="font-size:72; color:#38bdf8; font-family:Inter; text-align:center">
Grid Dots Preset
</h2>
<rm-counter from="0" to="1920" suffix="px" style="font-size:80; color:#ffffff; font-family:JetBrainsMono"></rm-counter>
</div>
</scene>
</rustmotion>
Loading