diff --git a/compositor_render/src/scene.rs b/compositor_render/src/scene.rs index dea952929..32e552a98 100644 --- a/compositor_render/src/scene.rs +++ b/compositor_render/src/scene.rs @@ -28,6 +28,7 @@ mod components; mod image_component; mod input_stream_component; mod layout; +mod rescaler_component; mod scene_state; mod shader_component; mod text_component; @@ -52,6 +53,7 @@ pub enum Component { Text(TextComponent), View(ViewComponent), Tiles(TilesComponent), + Rescaler(RescalerComponent), } /// Stateful version of a `Component`. Represents the same element as @@ -133,6 +135,7 @@ impl StatefulComponent { StatefulComponent::Layout(layout) => match layout { StatefulLayoutComponent::View(view) => view.intermediate_node(), StatefulLayoutComponent::Tiles(tiles) => tiles.intermediate_node(), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.intermediate_node(), }, } } @@ -153,6 +156,7 @@ impl Component { Component::Text(text) => text.stateful_component(ctx), Component::View(view) => view.stateful_component(ctx), Component::Tiles(tiles) => tiles.stateful_component(ctx), + Component::Rescaler(rescaler) => rescaler.stateful_component(ctx), } } } diff --git a/compositor_render/src/scene/components.rs b/compositor_render/src/scene/components.rs index e9683dd1e..efc7055d2 100644 --- a/compositor_render/src/scene/components.rs +++ b/compositor_render/src/scene/components.rs @@ -187,6 +187,25 @@ pub enum HorizontalPosition { RightOffset(f32), } +#[derive(Debug, Clone)] +pub struct RescalerComponent { + pub id: Option, + pub child: Box, + + pub position: Position, + pub transition: Option, + + pub mode: ResizeMode, + pub horizontal_align: HorizontalAlign, + pub vertical_align: VerticalAlign, +} + +#[derive(Debug, Clone, Copy)] +pub enum ResizeMode { + Fit, + Fill, +} + #[derive(Debug, Clone)] pub struct TilesComponent { pub id: Option, diff --git a/compositor_render/src/scene/layout.rs b/compositor_render/src/scene/layout.rs index 2b0d65fa0..49e820fb2 100644 --- a/compositor_render/src/scene/layout.rs +++ b/compositor_render/src/scene/layout.rs @@ -5,15 +5,16 @@ use compositor_common::scene::Resolution; use crate::transformations::layout::{self, LayoutContent, NestedLayout}; use super::{ - tiles_component::StatefulTilesComponent, view_component::StatefulViewComponent, - AbsolutePosition, ComponentId, HorizontalPosition, Position, Size, StatefulComponent, - VerticalPosition, + rescaler_component::StatefulRescalerComponent, tiles_component::StatefulTilesComponent, + view_component::StatefulViewComponent, AbsolutePosition, ComponentId, HorizontalPosition, + Position, Size, StatefulComponent, VerticalPosition, }; #[derive(Debug, Clone)] pub(super) enum StatefulLayoutComponent { View(StatefulViewComponent), Tiles(StatefulTilesComponent), + Rescaler(StatefulRescalerComponent), } #[derive(Debug)] @@ -43,6 +44,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(view) => view.layout(size, pts), StatefulLayoutComponent::Tiles(tiles) => tiles.layout(size, pts), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.layout(size, pts), } } @@ -50,6 +52,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(view) => view.position(pts), StatefulLayoutComponent::Tiles(tiles) => tiles.position(pts), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.position(pts), } } @@ -57,6 +60,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(view) => view.component_id(), StatefulLayoutComponent::Tiles(tiles) => tiles.component_id(), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.component_id(), } } @@ -64,6 +68,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(_) => "View", StatefulLayoutComponent::Tiles(_) => "Tiles", + StatefulLayoutComponent::Rescaler(_) => "Rescaler", } } @@ -71,6 +76,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(view) => view.children(), StatefulLayoutComponent::Tiles(tiles) => tiles.children(), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.children(), } } @@ -78,6 +84,7 @@ impl StatefulLayoutComponent { match self { StatefulLayoutComponent::View(view) => view.children_mut(), StatefulLayoutComponent::Tiles(tiles) => tiles.children_mut(), + StatefulLayoutComponent::Rescaler(rescaler) => rescaler.children_mut(), } } diff --git a/compositor_render/src/scene/rescaler_component.rs b/compositor_render/src/scene/rescaler_component.rs new file mode 100644 index 000000000..d3c89af9a --- /dev/null +++ b/compositor_render/src/scene/rescaler_component.rs @@ -0,0 +1,144 @@ +use std::{ops::Add, time::Duration}; + +use compositor_common::util::{ + align::{HorizontalAlign, VerticalAlign}, + ContinuousValue, InterpolationState, +}; + +use crate::transformations::layout::NestedLayout; + +use super::{ + components::RescalerComponent, layout::StatefulLayoutComponent, scene_state::BuildStateTreeCtx, + Component, ComponentId, IntermediateNode, Position, ResizeMode, SceneError, Size, + StatefulComponent, Transition, +}; + +mod interpolation; +mod layout; + +#[derive(Debug, Clone)] +pub(super) struct StatefulRescalerComponent { + start: Option, + end: RescalerComponentParam, + transition: Option, + child: Box, + start_pts: Duration, +} + +#[derive(Debug, Clone)] +struct RescalerComponentParam { + id: Option, + + position: Position, + mode: ResizeMode, + horizontal_align: HorizontalAlign, + vertical_align: VerticalAlign, +} + +impl StatefulRescalerComponent { + /// Generate state of the component for particular pts value. + fn transition_snapshot(&self, pts: Duration) -> RescalerComponentParam { + let (Some(transition), Some(start)) = (self.transition, &self.start) else { + return self.end.clone(); + }; + let interpolation_progress = InterpolationState(f64::min( + 1.0, + (pts.as_secs_f64() - self.start_pts.as_secs_f64()) / transition.duration.as_secs_f64(), + )); + ContinuousValue::interpolate(start, &self.end, interpolation_progress) + } + + fn remaining_transition_duration(&self, pts: Duration) -> Option { + self.transition.and_then(|transition| { + if self.start_pts + transition.duration > pts { + None + } else { + self.start_pts.add(transition.duration).checked_sub(pts) + } + }) + } + + pub(super) fn children(&self) -> Vec<&StatefulComponent> { + vec![&self.child] + } + + pub(super) fn children_mut(&mut self) -> Vec<&mut StatefulComponent> { + vec![&mut self.child] + } + + pub(super) fn position(&self, pts: Duration) -> Position { + self.transition_snapshot(pts).position + } + + pub(super) fn component_id(&self) -> Option<&ComponentId> { + self.end.id.as_ref() + } + + pub(super) fn intermediate_node(&self) -> IntermediateNode { + let children = { + let node = self.child.intermediate_node(); + match node { + IntermediateNode::Layout { root: _, children } => children, + _ => vec![node], + } + }; + + IntermediateNode::Layout { + root: StatefulLayoutComponent::Rescaler(self.clone()), + children, + } + } + + pub(super) fn layout(&self, size: Size, pts: Duration) -> NestedLayout { + self.transition_snapshot(pts).layout(size, &self.child, pts) + } +} + +impl RescalerComponent { + pub(super) fn stateful_component( + self, + ctx: &BuildStateTreeCtx, + ) -> Result { + let previous_state = self + .id + .as_ref() + .and_then(|id| ctx.prev_state.get(id)) + .and_then(|component| match component { + StatefulComponent::Layout(StatefulLayoutComponent::Rescaler(view_state)) => { + Some(view_state) + } + _ => None, + }); + + // TODO: to handle cases like transition from top to bottom this view needs + // to be further processed to use the same type of coordinates as end + let start = previous_state.map(|state| state.transition_snapshot(ctx.last_render_pts)); + // TODO: this is incorrect for non linear transformations + let transition = self.transition.or_else(|| { + let Some(previous_state) = previous_state else { + return None; + }; + let Some(duration) = previous_state.remaining_transition_duration(ctx.last_render_pts) + else { + return None; + }; + previous_state.transition.map(|_| Transition { duration }) + }); + let view = StatefulRescalerComponent { + start, + end: RescalerComponentParam { + id: self.id, + position: self.position, + mode: self.mode, + horizontal_align: self.horizontal_align, + vertical_align: self.vertical_align, + }, + transition, + child: Box::new(Component::stateful_component(*self.child, ctx)?), + start_pts: ctx.last_render_pts, + }; + Ok(StatefulComponent::Layout( + StatefulLayoutComponent::Rescaler(view), + )) + } +} diff --git a/compositor_render/src/scene/rescaler_component/interpolation.rs b/compositor_render/src/scene/rescaler_component/interpolation.rs new file mode 100644 index 000000000..7073c2f3e --- /dev/null +++ b/compositor_render/src/scene/rescaler_component/interpolation.rs @@ -0,0 +1,15 @@ +use compositor_common::util::{ContinuousValue, InterpolationState}; + +use super::RescalerComponentParam; + +impl ContinuousValue for RescalerComponentParam { + fn interpolate(start: &Self, end: &Self, state: InterpolationState) -> Self { + Self { + id: end.id.clone(), + position: ContinuousValue::interpolate(&start.position, &end.position, state), + mode: end.mode, + horizontal_align: end.horizontal_align, + vertical_align: end.vertical_align, + } + } +} diff --git a/compositor_render/src/scene/rescaler_component/layout.rs b/compositor_render/src/scene/rescaler_component/layout.rs new file mode 100644 index 000000000..40c4cb145 --- /dev/null +++ b/compositor_render/src/scene/rescaler_component/layout.rs @@ -0,0 +1,132 @@ +use std::time::Duration; + +use compositor_common::util::align::{HorizontalAlign, VerticalAlign}; + +use crate::{ + scene::{layout::StatefulLayoutComponent, ResizeMode, Size, StatefulComponent}, + transformations::layout::{Crop, LayoutContent, NestedLayout}, +}; + +use super::RescalerComponentParam; + +impl RescalerComponentParam { + pub(super) fn layout( + &self, + size: Size, + child: &StatefulComponent, + pts: Duration, + ) -> NestedLayout { + let child_width = child.width(pts); + let child_height = child.height(pts); + match (child_width, child_height) { + (None, None) => self.layout_with_scale(size, child, pts, 1.0), + (None, Some(child_height)) => { + self.layout_with_scale(size, child, pts, size.height / child_height) + } + (Some(child_width), None) => { + self.layout_with_scale(size, child, pts, size.width / child_width) + } + (Some(child_width), Some(child_height)) => { + let scale = match self.mode { + ResizeMode::Fit => { + f32::min(size.width / child_width, size.height / child_height) + } + ResizeMode::Fill => { + f32::max(size.width / child_width, size.height / child_height) + } + }; + self.layout_with_scale(size, child, pts, scale) + } + } + } + + fn layout_with_scale( + &self, + size: Size, + child: &StatefulComponent, + pts: Duration, + scale: f32, + ) -> NestedLayout { + let (content, children, child_nodes_count) = match child { + StatefulComponent::Layout(layout_component) => { + let children_layouts = layout_component.layout( + Size { + width: size.width / scale, + height: size.height / scale, + }, + pts, + ); + let child_nodes_count = children_layouts.child_nodes_count; + ( + LayoutContent::None, + vec![children_layouts], + child_nodes_count, + ) + } + _non_layout => (StatefulLayoutComponent::layout_content(child, 0), vec![], 1), + }; + + let top = match self.vertical_align { + VerticalAlign::Top => 0.0, + VerticalAlign::Bottom => child + .height(pts) + .map(|height| size.height - (height * scale)) + .unwrap_or(0.0), + VerticalAlign::Center | VerticalAlign::Justified => child + .height(pts) + .map(|height| (size.height - (height * scale)) / 2.0) + .unwrap_or(0.0), + }; + let left = match self.horizontal_align { + HorizontalAlign::Left => 0.0, + HorizontalAlign::Right => child + .width(pts) + .map(|width| (size.width - (width * scale))) + .unwrap_or(0.0), + HorizontalAlign::Center | HorizontalAlign::Justified => child + .width(pts) + .map(|width| (size.width - (width * scale)) / (2.0)) + .unwrap_or(0.0), + }; + + let width = child + .width(pts) + .map(|child_width| child_width * scale) + .unwrap_or(size.width); + let height = child + .height(pts) + .map(|child_height| child_height * scale) + .unwrap_or(size.height); + + NestedLayout { + top: 0.0, + left: 0.0, + width: size.width, + height: size.height, + rotation_degrees: 0.0, + scale_x: 1.0, + scale_y: 1.0, + crop: Some(Crop { + top: 0.0, + left: 0.0, + width: size.width, + height: size.height, + }), + content: LayoutContent::None, + children: vec![NestedLayout { + top, + left, + width, + height, + rotation_degrees: 0.0, + scale_x: scale, + scale_y: scale, + crop: None, + content, + child_nodes_count, + children, + }], + child_nodes_count, + } + } +} diff --git a/compositor_render/src/scene/validation.rs b/compositor_render/src/scene/validation.rs index a127a64e4..54a1148be 100644 --- a/compositor_render/src/scene/validation.rs +++ b/compositor_render/src/scene/validation.rs @@ -12,6 +12,7 @@ impl Component { Component::Text(text) => text.id.as_ref(), Component::View(view) => view.id.as_ref(), Component::Tiles(tiles) => tiles.id.as_ref(), + Component::Rescaler(rescaler) => rescaler.id.as_ref(), } } @@ -24,6 +25,7 @@ impl Component { Component::Text(_text) => vec![], Component::View(view) => view.children.iter().collect(), Component::Tiles(tiles) => tiles.children.iter().collect(), + Component::Rescaler(rescaler) => vec![rescaler.child.as_ref()], } } } diff --git a/compositor_render/src/scene/view_component.rs b/compositor_render/src/scene/view_component.rs index 15b2d12b0..9c6ab40d2 100644 --- a/compositor_render/src/scene/view_component.rs +++ b/compositor_render/src/scene/view_component.rs @@ -114,6 +114,7 @@ impl ViewComponent { // TODO: to handle cases like transition from top to bottom this view needs // to be further processed to use the same type of coordinates as end let start = previous_state.map(|state| state.view(ctx.last_render_pts)); + // TODO: this is incorrect for non linear transformations let transition = self.transition.or_else(|| { let Some(previous_state) = previous_state else { return None; diff --git a/compositor_render/src/scene/view_component/layout.rs b/compositor_render/src/scene/view_component/layout.rs index b6a560f52..6852b3be3 100644 --- a/compositor_render/src/scene/view_component/layout.rs +++ b/compositor_render/src/scene/view_component/layout.rs @@ -194,7 +194,9 @@ impl ViewComponentParam { children: &[StatefulComponent], pts: Duration, ) -> f32 { - let sum_size = self.sum_static_children_sizes(children, pts); + let sum_size = self + .sum_static_children_sizes(children, pts) + .max(0.000000001); // avoid division by 0 let (max_size, max_alternative_size) = match self.direction { super::ViewChildrenDirection::Row => (size.width, size.height), super::ViewChildrenDirection::Column => (size.height, size.width), diff --git a/compositor_render/src/transformations/layout.rs b/compositor_render/src/transformations/layout.rs index fb445ce0d..ad7fafb2a 100644 --- a/compositor_render/src/transformations/layout.rs +++ b/compositor_render/src/transformations/layout.rs @@ -71,6 +71,8 @@ pub struct NestedLayout { pub width: f32, pub height: f32, pub rotation_degrees: f32, + /// scale will affect content/children, but not the properties of current layout like + /// top/left/widht/height pub scale_x: f32, pub scale_y: f32, /// Crop is applied before scaling. @@ -114,6 +116,7 @@ impl LayoutNode { .layout_provider .layouts(pts, &input_resolutions) .flatten(0); + let layout_count = layouts.len(); let output_resolution = self.layout_provider.resolution(pts); diff --git a/examples/image.rs b/examples/image.rs index a9c7d423e..1a07f5fc0 100644 --- a/examples/image.rs +++ b/examples/image.rs @@ -93,7 +93,7 @@ fn start_example_client_code() -> Result<()> { "asset_type": "svg", "image_id": "example_svg", "path": PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/assets/rust.svg"), - "resolution": { "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.height }, + "resolution": { "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.width}, }))?; common::post(&json!({ "type": "register", @@ -106,15 +106,15 @@ fn start_example_client_code() -> Result<()> { let new_image = |image_id, label| { json!({ "type": "view", + "background_color_rgba": "#0000FFFF", "children": [ { - "type": "view", - "background_color_rgba": "#0000FFFF", - "overflow": "fit", - "children": [{ + "type": "rescaler", + "mode": "fit", + "child": { "type": "image", "image_id": image_id, - }] + } }, { "type": "view", diff --git a/examples/transition.rs b/examples/transition.rs index 50ceb5877..b7bffe54b 100644 --- a/examples/transition.rs +++ b/examples/transition.rs @@ -159,7 +159,6 @@ fn start_example_client_code() -> Result<()> { { "type": "view", "id": "resized", - "overflow": "fit", "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.height, "top": 0, @@ -169,13 +168,18 @@ fn start_example_client_code() -> Result<()> { }, "children": [ { - "type": "shader", - "shader_id": "example_shader", - "resolution": { "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.height }, - "children": [{ - "type": "input_stream", - "input_id": "input_1", - }] + "type": "rescaler", + "mode": "fit", + "child": { + "type": "shader", + "shader_id": "example_shader", + "resolution": { "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.height }, + "children": [{ + "type": "input_stream", + "input_id": "input_1", + }] + } + } ] } diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json index 0c0329271..ce7621c14 100644 --- a/schemas/scene.schema.json +++ b/schemas/scene.schema.json @@ -568,6 +568,132 @@ "type" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bottom": { + "description": "Distance between the bottom edge of this component and the bottom edge of its parent. If this field is defined, this element will be absolutely positioned, instead of being laid out by it's parent.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "child": { + "$ref": "#/definitions/Component" + }, + "height": { + "description": "Height of a component in pixels. Required when using absolute positioning.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "horizontal_align": { + "anyOf": [ + { + "$ref": "#/definitions/HorizontalAlign" + }, + { + "type": "null" + } + ] + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/ComponentId" + }, + { + "type": "null" + } + ] + }, + "left": { + "description": "Distance between the left edge of this component and the left edge of its parent. If this field is defined, this element will be absolutely positioned, instead of being laid out by it's parent.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ResizeMode" + }, + { + "type": "null" + } + ] + }, + "right": { + "description": "Distance between the right edge of this component and the right edge of its parent. If this field is defined, this element will be absolutely positioned, instead of being laid out by it's parent.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "rotation": { + "description": "Rotation of a component in degrees. If this field is defined, this element will be absolutely positioned, instead of being laid out by it's parent.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "top": { + "description": "Distance between the top edge of this component and the top edge of its parent. If this field is defined, then component will ignore a layout defined by its parent.", + "format": "float", + "type": [ + "number", + "null" + ] + }, + "transition": { + "anyOf": [ + { + "$ref": "#/definitions/Transition" + }, + { + "type": "null" + } + ], + "description": "Defines how this component will behave during a scene update. This will only have an effect if previous scene already contained a View component with the same id." + }, + "type": { + "enum": [ + "rescaler" + ], + "type": "string" + }, + "vertical_align": { + "anyOf": [ + { + "$ref": "#/definitions/VerticalAlign" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Width of a component in pixels. Required when using absolute positioning.", + "format": "float", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "child", + "type" + ], + "type": "object" } ] }, @@ -620,6 +746,13 @@ "RendererId": { "type": "string" }, + "ResizeMode": { + "enum": [ + "fit", + "fill" + ], + "type": "string" + }, "Resolution": { "properties": { "height": { diff --git a/snapshot_tests/rescaler/fill_input_stream.scene.json b/snapshot_tests/rescaler/fill_input_stream.scene.json new file mode 100644 index 000000000..0894bf722 --- /dev/null +++ b/snapshot_tests/rescaler/fill_input_stream.scene.json @@ -0,0 +1,26 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fill", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json b/snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json new file mode 100644 index 000000000..851f75646 --- /dev/null +++ b/snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json @@ -0,0 +1,28 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fill", + "horizontal_align": "right", + "vertical_align": "bottom", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json b/snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json new file mode 100644 index 000000000..bfd5d95bf --- /dev/null +++ b/snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json @@ -0,0 +1,28 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fill", + "horizontal_align": "left", + "vertical_align": "top", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_input_stream.scene.json b/snapshot_tests/rescaler/fit_input_stream.scene.json new file mode 100644 index 000000000..850bad4c7 --- /dev/null +++ b/snapshot_tests/rescaler/fit_input_stream.scene.json @@ -0,0 +1,26 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json b/snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json new file mode 100644 index 000000000..635498c98 --- /dev/null +++ b/snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json @@ -0,0 +1,28 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "horizontal_align": "right", + "vertical_align": "bottom", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json b/snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json new file mode 100644 index 000000000..053b11716 --- /dev/null +++ b/snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json @@ -0,0 +1,28 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "horizontal_align": "left", + "vertical_align": "top", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "input_stream", + "input_id": "input_1" + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_view_with_known_height.scene.json b/snapshot_tests/rescaler/fit_view_with_known_height.scene.json new file mode 100644 index 000000000..cf5bdf18c --- /dev/null +++ b/snapshot_tests/rescaler/fit_view_with_known_height.scene.json @@ -0,0 +1,27 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "view", + "background_color_rgba": "#0000FFFF", + "height": 100 + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_view_with_known_width.scene.json b/snapshot_tests/rescaler/fit_view_with_known_width.scene.json new file mode 100644 index 000000000..bb6d36fb0 --- /dev/null +++ b/snapshot_tests/rescaler/fit_view_with_known_width.scene.json @@ -0,0 +1,27 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child":{ + "type": "view", + "background_color_rgba": "#0000FFFF", + "width": 200 + } + } + ] + } +} diff --git a/snapshot_tests/rescaler/fit_view_with_unknown_width_and_height.scene.json b/snapshot_tests/rescaler/fit_view_with_unknown_width_and_height.scene.json new file mode 100644 index 000000000..d0fb92d1e --- /dev/null +++ b/snapshot_tests/rescaler/fit_view_with_unknown_width_and_height.scene.json @@ -0,0 +1,26 @@ +{ + "output_id": "output_1", + "root": { + "type": "view", + "children": [ + { + "type": "view", + "background_color_rgba": "#FF0000FF", + "width": 160, + "height": 90 + }, + { + "type": "rescaler", + "mode": "fit", + "top": 90, + "left": 160, + "width": 320, + "height": 180, + "child": { + "type": "view", + "background_color_rgba": "#0000FFFF" + } + } + ] + } +} diff --git a/snapshot_tests/snapshots b/snapshot_tests/snapshots index 5d572bf4d..c6e89cc69 160000 --- a/snapshot_tests/snapshots +++ b/snapshot_tests/snapshots @@ -1 +1 @@ -Subproject commit 5d572bf4ddc140760a3dc3423bc54db9e6ea72ce +Subproject commit c6e89cc695abec2b957954a7b171addfa1bebcf0 diff --git a/src/bin/update_snapshots/main.rs b/src/bin/update_snapshots/main.rs index 37b4bdfa7..49aec9074 100644 --- a/src/bin/update_snapshots/main.rs +++ b/src/bin/update_snapshots/main.rs @@ -20,11 +20,18 @@ use crate::{ fn main() { println!("Updating snapshots:"); - let test: Vec<_> = snapshot_tests() - .into_iter() - .map(TestCaseInstance::new) - .collect(); - for test in test.iter() { + let tests: Vec<_> = snapshot_tests(); + let has_only_flag = tests.iter().any(|t| t.only); + let tests: Vec<_> = if has_only_flag { + tests + .into_iter() + .filter(|t| t.only) + .map(TestCaseInstance::new) + .collect() + } else { + tests.into_iter().map(TestCaseInstance::new).collect() + }; + for test in tests.iter() { for pts in &test.case.timestamps { let (snapshots, Err(_)) = test.test_snapshots_for_pts(*pts) else { println!("PASS: \"{}\" (pts: {}ms)", test.case.name, pts.as_millis()); @@ -63,16 +70,16 @@ fn main() { } } } - - // Check for unused snapshots - let snapshot_paths = test - .iter() - .flat_map(TestCaseInstance::snapshot_paths) - .collect::>(); - for path in find_unused_snapshots(&snapshot_paths, snapshots_path()) { - println!("Removed unused snapshot {path:?}"); - fs::remove_file(path).unwrap(); + if !has_only_flag { + // Check for unused snapshots + let snapshot_paths = tests + .iter() + .flat_map(TestCaseInstance::snapshot_paths) + .collect::>(); + for path in find_unused_snapshots(&snapshot_paths, snapshots_path()) { + println!("Removed unused snapshot {path:?}"); + fs::remove_file(path).unwrap(); + } } - println!("Update finished"); } diff --git a/src/snapshot_tests/test_case.rs b/src/snapshot_tests/test_case.rs index 3f4ff7350..79a9af1af 100644 --- a/src/snapshot_tests/test_case.rs +++ b/src/snapshot_tests/test_case.rs @@ -20,6 +20,7 @@ pub struct TestCase { pub renderers: Vec<&'static str>, pub timestamps: Vec, pub outputs: Outputs, + pub only: bool, pub allowed_error: f32, } @@ -36,6 +37,7 @@ impl Default for TestCase { renderers: Vec::new(), timestamps: vec![Duration::from_secs(0)], outputs: Outputs::Scene(vec![]), + only: false, allowed_error: 130.0, } } diff --git a/src/snapshot_tests/tests.rs b/src/snapshot_tests/tests.rs index 33df48c7d..b0ee5ec50 100644 --- a/src/snapshot_tests/tests.rs +++ b/src/snapshot_tests/tests.rs @@ -17,9 +17,186 @@ pub fn snapshot_tests() -> Vec { tests.append(&mut image_snapshot_tests()); tests.append(&mut text_snapshot_tests()); tests.append(&mut tiles_snapshot_tests()); + tests.append(&mut rescaler_snapshot_tests()); tests } +pub fn rescaler_snapshot_tests() -> Vec { + let higher_than_default = Resolution { + width: DEFAULT_RESOLUTION.width, + height: DEFAULT_RESOLUTION.height + 100, + }; + let lower_than_default = Resolution { + width: DEFAULT_RESOLUTION.width, + height: DEFAULT_RESOLUTION.height - 100, + }; + let portrait_resolution = Resolution { + width: 360, + height: 640, + }; + Vec::from([ + TestCase { + name: "rescaler/fit_view_with_known_height", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_view_with_known_height.scene.json"), + DEFAULT_RESOLUTION, + )]), + ..Default::default() + }, + TestCase { + name: "rescaler/fit_view_with_known_width", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_view_with_known_width.scene.json"), + DEFAULT_RESOLUTION, + )]), + ..Default::default() + }, + TestCase { + name: "rescaler/fit_view_with_unknown_width_and_height", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_view_with_unknown_width_and_height.scene.json"), + DEFAULT_RESOLUTION, + )]), + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_inverted_aspect_ratio_align_top_left", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_inverted_aspect_ratio_align_bottom_right", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_lower_aspect_ratio_align_bottom_right", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, lower_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_lower_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, lower_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_higher_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, higher_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_inverted_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fill_input_stream_matching_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fill_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new(1)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_lower_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, lower_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_higher_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, higher_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_higher_aspect_ratio_small_resolution", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, Resolution { width: higher_than_default.width / 10, height: higher_than_default.height / 10 })], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_inverted_aspect_ratio_align_top_left", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_inverted_aspect_ratio_align_bottom_right", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_lower_aspect_ratio_align_bottom_right", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, lower_than_default)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_inverted_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new_with_resolution(1, portrait_resolution)], + ..Default::default() + }, + TestCase { + name: "rescaler/fit_input_stream_matching_aspect_ratio", + outputs: Outputs::Scene(vec![( + include_str!("../../snapshot_tests/rescaler/fit_input_stream.scene.json"), + DEFAULT_RESOLUTION, + )]), + inputs: vec![TestInput::new(1)], + ..Default::default() + }, + ]) +} + pub fn tiles_snapshot_tests() -> Vec { let input1 = TestInput::new(1); let input2 = TestInput::new(2); diff --git a/src/types/component.rs b/src/types/component.rs index 9a489c4c7..25a191f0b 100644 --- a/src/types/component.rs +++ b/src/types/component.rs @@ -16,6 +16,7 @@ pub enum Component { Image(Image), Text(Text), Tiles(Tiles), + Rescaler(Rescaler), } /// Component representing incoming RTP stream. Specific streams can be identified @@ -98,6 +99,53 @@ pub enum ViewDirection { Column, } +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Rescaler { + // TODO: better name + pub id: Option, + pub child: Box, + + pub mode: Option, + pub horizontal_align: Option, + pub vertical_align: Option, + + /// Width of a component in pixels. Required when using absolute positioning. + pub width: Option, + /// Height of a component in pixels. Required when using absolute positioning. + pub height: Option, + + /// Distance between the top edge of this component and the top edge of its parent. + /// If this field is defined, then component will ignore a layout defined by its parent. + pub top: Option, + /// Distance between the left edge of this component and the left edge of its parent. + /// If this field is defined, this element will be absolutely positioned, instead of being + /// laid out by it's parent. + pub left: Option, + /// Distance between the bottom edge of this component and the bottom edge of its parent. + /// If this field is defined, this element will be absolutely positioned, instead of being + /// laid out by it's parent. + pub bottom: Option, + /// Distance between the right edge of this component and the right edge of its parent. + /// If this field is defined, this element will be absolutely positioned, instead of being + /// laid out by it's parent. + pub right: Option, + /// Rotation of a component in degrees. If this field is defined, this element will be + /// absolutely positioned, instead of being laid out by it's parent. + pub rotation: Option, + + /// Defines how this component will behave during a scene update. This will only have an + /// effect if previous scene already contained a View component with the same id. + pub transition: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ResizeMode { + Fit, + Fill, +} + /// WebView component renders a website using Chromium. #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/src/types/from_component.rs b/src/types/from_component.rs index cf63343bf..c2279a465 100644 --- a/src/types/from_component.rs +++ b/src/types/from_component.rs @@ -21,6 +21,7 @@ impl TryFrom for scene::Component { Component::Image(image) => Ok(Self::Image(image.into())), Component::Text(text) => Ok(Self::Text(text.try_into()?)), Component::Tiles(tiles) => Ok(Self::Tiles(tiles.try_into()?)), + Component::Rescaler(rescaler) => Ok(Self::Rescaler(rescaler.try_into()?)), } } } @@ -114,6 +115,78 @@ impl TryFrom for scene::ViewComponent { } } +impl TryFrom for scene::RescalerComponent { + type Error = TypeError; + + fn try_from(rescaler: Rescaler) -> Result { + const WIDTH_REQUIRED_MSG: &str = + "\"Rescaler\" component with absolute positioning requires \"width\" to be specified."; + const HEIGHT_REQUIRED_MSG: &str = + "\"Rescaler\" component with absolute positioning requires \"height\" to be specified."; + const VERTICAL_REQUIRED_MSG: &str = + "\"Rescaler\" component with absolute positioning requires either \"top\" or \"bottom\" coordinate."; + const VERTICAL_ONLY_ONE_MSG: &str = "Fields \"top\" and \"bottom\" are mutually exclusive, you can only specify one on a \"Rescaler\" component."; + const HORIZONTAL_REQUIRED_MSG: &str = + "Non-static \"Rescaler\" component requires either \"left\" or \"right\" coordinate."; + const HORIZONTAL_ONLY_ONE_MSG: &str = "Fields \"left\" and \"right\" are mutually exclusive, you can only specify one on a \"Rescaler\" component."; + let is_absolute_position = rescaler.top.is_some() + || rescaler.bottom.is_some() + || rescaler.left.is_some() + || rescaler.right.is_some() + || rescaler.rotation.is_some(); + let position = if is_absolute_position { + let position_vertical = match (rescaler.top, rescaler.bottom) { + (Some(top), None) => scene::VerticalPosition::TopOffset(top), + (None, Some(bottom)) => scene::VerticalPosition::BottomOffset(bottom), + (None, None) => return Err(TypeError::new(VERTICAL_REQUIRED_MSG)), + (Some(_), Some(_)) => return Err(TypeError::new(VERTICAL_ONLY_ONE_MSG)), + }; + let position_horizontal = match (rescaler.left, rescaler.right) { + (Some(left), None) => scene::HorizontalPosition::LeftOffset(left), + (None, Some(right)) => scene::HorizontalPosition::RightOffset(right), + (None, None) => return Err(TypeError::new(HORIZONTAL_REQUIRED_MSG)), + (Some(_), Some(_)) => return Err(TypeError::new(HORIZONTAL_ONLY_ONE_MSG)), + }; + Position::Absolute(scene::AbsolutePosition { + width: (rescaler + .width + .ok_or_else(|| TypeError::new(WIDTH_REQUIRED_MSG))?), + height: (rescaler + .height + .ok_or_else(|| TypeError::new(HEIGHT_REQUIRED_MSG))?), + position_horizontal, + position_vertical, + rotation_degrees: rescaler.rotation.unwrap_or(0.0), + }) + } else { + Position::Static { + width: rescaler.width, + height: rescaler.height, + } + }; + let mode = match rescaler.mode { + Some(ResizeMode::Fit) => scene::ResizeMode::Fit, + Some(ResizeMode::Fill) => scene::ResizeMode::Fill, + None => scene::ResizeMode::Fit, + }; + Ok(Self { + id: rescaler.id.map(Into::into), + child: Box::new((*rescaler.child).try_into()?), + position, + mode, + horizontal_align: rescaler + .horizontal_align + .unwrap_or(HorizontalAlign::Center) + .into(), + vertical_align: rescaler + .vertical_align + .unwrap_or(VerticalAlign::Center) + .into(), + transition: rescaler.transition.map(Into::into), + }) + } +} + impl TryFrom for scene::ShaderComponent { type Error = TypeError;