diff --git a/CHANGELOG.md b/CHANGELOG.md index 24cf8e270..8fff1535a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,14 @@ ### 💥 Breaking changes - Drop support for `SHADER_UNUSED_VERTEX_OUTPUT` `wgpu` feature. ([#733](https://github.com/software-mansion/live-compositor/pull/733)) by [@jerzywilczek](https://github.com/jerzywilczek) +- Rename component properties describing color. Remove `_rgba` suffix. ([#896](https://github.com/software-mansion/live-compositor/issues/896)) by [@BrtqKr](https://github.com/BrtqKr) ### ✨ New features - Add `loop` option for MP4 input. ([#699](https://github.com/software-mansion/live-compositor/pull/699) by [@WojciechBarczynski](https://github.com/WojciechBarczynski)) - Add `LIVE_COMPOSITOR_LOG_FILE` environment variable to enable logging to file ([#853](https://github.com/software-mansion/live-compositor/pull/853) by [@wkozyra95](https://github.com/wkozyra95)) - Add border, border radius and box shadow options to `Rescaler` and `View` components. ([#815](https://github.com/software-mansion/live-compositor/pull/815) by [@WojciechBarczynski](https://github.com/WojciechBarczynski), ([#839](https://github.com/software-mansion/live-compositor/pull/839), [#842](https://github.com/software-mansion/live-compositor/pull/842), [#858](https://github.com/software-mansion/live-compositor/pull/858) by [@wkozyra95](https://github.com/wkozyra95)) - +- Extend supported color formats. ([#896](https://github.com/software-mansion/live-compositor/issues/896)) by [@BrtqKr](https://github.com/BrtqKr) ### 🐛 Bug fixes diff --git a/build_tools/nix/flake.nix b/build_tools/nix/flake.nix index 7e0c443f8..5a8aca648 100644 --- a/build_tools/nix/flake.nix +++ b/build_tools/nix/flake.nix @@ -77,7 +77,7 @@ # Fixes "ffplay" used in examples on Linux (not needed on NixOS) env.LIBGL_DRIVERS_PATH = "${pkgs.mesa.drivers}/lib/dri"; - env.LIBCLANG_PATH = "${pkgs.llvmPackages_16.libclang.lib}/lib"; + env.LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; env.LD_LIBRARY_PATH = lib.makeLibraryPath (libcefDependencies ++ [ pkgs.mesa.drivers pkgs.libGL pkgs.blackmagic-desktop-video ]); inputsFrom = [ packageWithoutChromium ]; @@ -89,7 +89,7 @@ nixos = pkgs.mkShell { packages = devDependencies ++ [ pkgs.blackmagic-desktop-video]; - env.LIBCLANG_PATH = "${pkgs.llvmPackages_16.libclang.lib}/lib"; + env.LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; env.LD_LIBRARY_PATH = lib.makeLibraryPath (libcefDependencies ++ [ pkgs.blackmagic-desktop-video ]); inputsFrom = [ packageWithoutChromium ]; diff --git a/build_tools/nix/package.nix b/build_tools/nix/package.nix index f28ce969f..d54c9b8a1 100644 --- a/build_tools/nix/package.nix +++ b/build_tools/nix/package.nix @@ -2,7 +2,7 @@ , ffmpeg_7-headless , openssl , pkg-config -, llvmPackages_16 +, llvmPackages , libGL , cmake , libopus @@ -20,6 +20,7 @@ let libopus libGL vulkan-loader + stdenv.cc.cc ] ++ lib.optionals stdenv.isDarwin [ darwin.apple_sdk.frameworks.Metal darwin.apple_sdk.frameworks.Foundation @@ -43,9 +44,9 @@ rustPlatform.buildRustPackage { doCheck = false; inherit buildInputs; - nativeBuildInputs = [ pkg-config llvmPackages_16.clang cmake makeWrapper ]; + nativeBuildInputs = [ pkg-config llvmPackages.clang cmake makeWrapper ]; - env.LIBCLANG_PATH = "${llvmPackages_16.libclang.lib}/lib"; + env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; postFixup = '' diff --git a/compositor_api/src/types.rs b/compositor_api/src/types.rs index 5e0f6ccce..b4a15ffb8 100644 --- a/compositor_api/src/types.rs +++ b/compositor_api/src/types.rs @@ -8,6 +8,7 @@ mod audio; mod component; #[cfg(not(target_arch = "wasm32"))] mod from_audio; +mod from_color; mod from_component; #[cfg(not(target_arch = "wasm32"))] mod from_register_input; diff --git a/compositor_api/src/types/component.rs b/compositor_api/src/types/component.rs index d8cac4430..549a45e4d 100644 --- a/compositor_api/src/types/component.rs +++ b/compositor_api/src/types/component.rs @@ -79,7 +79,7 @@ pub struct View { pub overflow: Option, /// (**default=`"#00000000"`**) Background color in a `"#RRGGBBAA"` format. - pub background_color_rgba: Option, + pub background_color: Option, /// (**default=`0.0`**) Radius of a rounded corner. pub border_radius: Option, @@ -88,7 +88,7 @@ pub struct View { pub border_width: Option, /// (**default=`"#00000000"`**) Border color in a `"#RRGGBBAA"` format. - pub border_color_rgba: Option, + pub border_color: Option, /// List of box shadows. pub box_shadow: Option>, @@ -99,7 +99,7 @@ pub struct View { pub struct BoxShadow { pub offset_x: Option, pub offset_y: Option, - pub color_rgba: Option, + pub color: Option, pub blur_radius: Option, } @@ -190,7 +190,7 @@ pub struct Rescaler { pub border_width: Option, /// (**default=`"#00000000"`**) Border color in a `"#RRGGBBAA"` format. - pub border_color_rgba: Option, + pub border_color: Option, /// List of box shadows. pub box_shadow: Option>, @@ -311,9 +311,9 @@ pub struct Text { /// Distance between lines in pixels. Defaults to the value of the `font_size` property. pub line_height: Option, /// (**default=`"#FFFFFFFF"`**) Font color in `#RRGGBBAA` format. - pub color_rgba: Option, + pub color: Option, /// (**default=`"#00000000"`**) Background color in `#RRGGBBAA` format. - pub background_color_rgba: Option, + pub background_color: Option, /// (**default=`"Verdana"`**) Font family. Provide [family-name](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#family-name-value) /// for a specific font. "generic-family" values like e.g. "sans-serif" will not work. pub font_family: Option>, @@ -399,7 +399,7 @@ pub struct Tiles { pub height: Option, /// (**default=`"#00000000"`**) Background color in a `"#RRGGBBAA"` format. - pub background_color_rgba: Option, + pub background_color: Option, /// (**default=`"16:9"`**) Aspect ratio of a tile in `"W:H"` format, where W and H are integers. pub tile_aspect_ratio: Option, /// (**default=`0`**) Margin of each tile in pixels. diff --git a/compositor_api/src/types/from_color.rs b/compositor_api/src/types/from_color.rs new file mode 100644 index 000000000..330828445 --- /dev/null +++ b/compositor_api/src/types/from_color.rs @@ -0,0 +1,250 @@ +use compositor_render::scene; + +use super::util::*; + +impl TryFrom for scene::RGBAColor { + type Error = TypeError; + fn try_from(value: RGBAColor) -> std::result::Result { + let s = &value.0.trim(); + if let Some(named_color) = parse_named_color(s) { + return Ok(named_color); + } + if s.starts_with('#') { + return match s.len() { + 7 => parse_hex(s), + 9 => parse_hex_rgba(s), + _ => Err(TypeError::new( + "Invalid format. Color has to be in #RRGGBB or #RRGGBBAA format.", + )), + }; + } + if s.starts_with("rgb(") { + return parse_rgb(s); + } + + if s.starts_with("rgba(") { + return parse_rgba(s); + } + + Err(TypeError::new("Unsupported color format.")) + } +} + +fn parse_color_channel(value: &str, radix: u32) -> Result { + u8::from_str_radix(value.trim(), radix).map_err(|_err| { + TypeError::new("Invalid format. Color representation is not a valid number.") + }) +} + +fn parse_hex(s: &str) -> Result { + let (r, g, b) = (&s[1..3], &s[3..5], &s[5..7]); + Ok(scene::RGBAColor( + parse_color_channel(r, 16)?, + parse_color_channel(g, 16)?, + parse_color_channel(b, 16)?, + 255, + )) +} + +fn parse_hex_rgba(s: &str) -> Result { + let (r, g, b, a) = (&s[1..3], &s[3..5], &s[5..7], &s[7..9]); + Ok(scene::RGBAColor( + parse_color_channel(r, 16)?, + parse_color_channel(g, 16)?, + parse_color_channel(b, 16)?, + parse_color_channel(a, 16)?, + )) +} + +fn parse_rgb(s: &str) -> Result { + let inner_s = s.trim_start_matches("rgb(").trim_end_matches(')'); + let parts: Vec<&str> = inner_s.split(',').collect(); + match parts.as_slice() { + [r, g, b] => Ok(scene::RGBAColor( + parse_color_channel(r, 10)?, + parse_color_channel(g, 10)?, + parse_color_channel(b, 10)?, + 255, + )), + _ => Err(TypeError::new("Invalid RGB format.")), + } +} + +fn parse_rgba(s: &str) -> Result { + let inner_s = s.trim_start_matches("rgba(").trim_end_matches(')'); + let parts: Vec<&str> = inner_s.split(',').collect(); + let [r, g, b, a] = parts.as_slice() else { + return Err(TypeError::new( + "Expected three color components and alpha channel.", + )); + }; + let a = a + .parse::() + .map_err(|_| TypeError::new("Alpha channel parsing failed."))?; + if !(0.0..=1.0).contains(&a) { + return Err(TypeError::new( + "Alpha value out of range. It must be between 0.0 and 1.0", + )); + } + Ok(scene::RGBAColor( + parse_color_channel(r, 10)?, + parse_color_channel(g, 10)?, + parse_color_channel(b, 10)?, + (a * 255.0).round() as u8, + )) +} + +fn parse_named_color(color_name: &str) -> Option { + match color_name { + "aliceblue" => Some(scene::RGBAColor(240, 248, 255, 255)), + "antiquewhite" => Some(scene::RGBAColor(250, 235, 215, 255)), + "aqua" => Some(scene::RGBAColor(0, 255, 255, 255)), + "aquamarine" => Some(scene::RGBAColor(127, 255, 212, 255)), + "azure" => Some(scene::RGBAColor(240, 255, 255, 255)), + "beige" => Some(scene::RGBAColor(245, 245, 220, 255)), + "bisque" => Some(scene::RGBAColor(255, 228, 196, 255)), + "black" => Some(scene::RGBAColor(0, 0, 0, 255)), + "blanchedalmond" => Some(scene::RGBAColor(255, 235, 205, 255)), + "blue" => Some(scene::RGBAColor(0, 0, 255, 255)), + "blueviolet" => Some(scene::RGBAColor(138, 43, 226, 255)), + "brown" => Some(scene::RGBAColor(165, 42, 42, 255)), + "burlywood" => Some(scene::RGBAColor(222, 184, 135, 255)), + "burntsienna" => Some(scene::RGBAColor(234, 126, 93, 255)), + "cadetblue" => Some(scene::RGBAColor(95, 158, 160, 255)), + "chartreuse" => Some(scene::RGBAColor(127, 255, 0, 255)), + "chocolate" => Some(scene::RGBAColor(210, 105, 30, 255)), + "coral" => Some(scene::RGBAColor(255, 127, 80, 255)), + "cornflowerblue" => Some(scene::RGBAColor(100, 149, 237, 255)), + "cornsilk" => Some(scene::RGBAColor(255, 248, 220, 255)), + "crimson" => Some(scene::RGBAColor(220, 20, 60, 255)), + "cyan" => Some(scene::RGBAColor(0, 255, 255, 255)), + "darkblue" => Some(scene::RGBAColor(0, 0, 139, 255)), + "darkcyan" => Some(scene::RGBAColor(0, 139, 139, 255)), + "darkgoldenrod" => Some(scene::RGBAColor(184, 134, 11, 255)), + "darkgray" => Some(scene::RGBAColor(169, 169, 169, 255)), + "darkgreen" => Some(scene::RGBAColor(0, 100, 0, 255)), + "darkgrey" => Some(scene::RGBAColor(169, 169, 169, 255)), + "darkkhaki" => Some(scene::RGBAColor(189, 183, 107, 255)), + "darkmagenta" => Some(scene::RGBAColor(139, 0, 139, 255)), + "darkolivegreen" => Some(scene::RGBAColor(85, 107, 47, 255)), + "darkorange" => Some(scene::RGBAColor(255, 140, 0, 255)), + "darkorchid" => Some(scene::RGBAColor(153, 50, 204, 255)), + "darkred" => Some(scene::RGBAColor(139, 0, 0, 255)), + "darksalmon" => Some(scene::RGBAColor(233, 150, 122, 255)), + "darkseagreen" => Some(scene::RGBAColor(143, 188, 143, 255)), + "darkslateblue" => Some(scene::RGBAColor(72, 61, 139, 255)), + "darkslategray" => Some(scene::RGBAColor(47, 79, 79, 255)), + "darkslategrey" => Some(scene::RGBAColor(47, 79, 79, 255)), + "darkturquoise" => Some(scene::RGBAColor(0, 206, 209, 255)), + "darkviolet" => Some(scene::RGBAColor(148, 0, 211, 255)), + "deeppink" => Some(scene::RGBAColor(255, 20, 147, 255)), + "deepskyblue" => Some(scene::RGBAColor(0, 191, 255, 255)), + "dimgray" => Some(scene::RGBAColor(105, 105, 105, 255)), + "dimgrey" => Some(scene::RGBAColor(105, 105, 105, 255)), + "dodgerblue" => Some(scene::RGBAColor(30, 144, 255, 255)), + "firebrick" => Some(scene::RGBAColor(178, 34, 34, 255)), + "floralwhite" => Some(scene::RGBAColor(255, 250, 240, 255)), + "forestgreen" => Some(scene::RGBAColor(34, 139, 34, 255)), + "fuchsia" => Some(scene::RGBAColor(255, 0, 255, 255)), + "gainsboro" => Some(scene::RGBAColor(220, 220, 220, 255)), + "ghostwhite" => Some(scene::RGBAColor(248, 248, 255, 255)), + "gold" => Some(scene::RGBAColor(255, 215, 0, 255)), + "goldenrod" => Some(scene::RGBAColor(218, 165, 32, 255)), + "gray" => Some(scene::RGBAColor(128, 128, 128, 255)), + "green" => Some(scene::RGBAColor(0, 128, 0, 255)), + "greenyellow" => Some(scene::RGBAColor(173, 255, 47, 255)), + "grey" => Some(scene::RGBAColor(128, 128, 128, 255)), + "honeydew" => Some(scene::RGBAColor(240, 255, 240, 255)), + "hotpink" => Some(scene::RGBAColor(255, 105, 180, 255)), + "indianred" => Some(scene::RGBAColor(205, 92, 92, 255)), + "indigo" => Some(scene::RGBAColor(75, 0, 130, 255)), + "ivory" => Some(scene::RGBAColor(255, 255, 240, 255)), + "khaki" => Some(scene::RGBAColor(240, 230, 140, 255)), + "lavender" => Some(scene::RGBAColor(230, 230, 250, 255)), + "lavenderblush" => Some(scene::RGBAColor(255, 240, 245, 255)), + "lawngreen" => Some(scene::RGBAColor(124, 252, 0, 255)), + "lemonchiffon" => Some(scene::RGBAColor(255, 250, 205, 255)), + "lightblue" => Some(scene::RGBAColor(173, 216, 230, 255)), + "lightcoral" => Some(scene::RGBAColor(240, 128, 128, 255)), + "lightcyan" => Some(scene::RGBAColor(224, 255, 255, 255)), + "lightgoldenrodyellow" => Some(scene::RGBAColor(250, 250, 210, 255)), + "lightgray" => Some(scene::RGBAColor(211, 211, 211, 255)), + "lightgreen" => Some(scene::RGBAColor(144, 238, 144, 255)), + "lightgrey" => Some(scene::RGBAColor(211, 211, 211, 255)), + "lightpink" => Some(scene::RGBAColor(255, 182, 193, 255)), + "lightsalmon" => Some(scene::RGBAColor(255, 160, 122, 255)), + "lightseagreen" => Some(scene::RGBAColor(32, 178, 170, 255)), + "lightskyblue" => Some(scene::RGBAColor(135, 206, 250, 255)), + "lightslategray" => Some(scene::RGBAColor(119, 136, 153, 255)), + "lightslategrey" => Some(scene::RGBAColor(119, 136, 153, 255)), + "lightsteelblue" => Some(scene::RGBAColor(176, 196, 222, 255)), + "lightyellow" => Some(scene::RGBAColor(255, 255, 224, 255)), + "lime" => Some(scene::RGBAColor(0, 255, 0, 255)), + "limegreen" => Some(scene::RGBAColor(50, 205, 50, 255)), + "linen" => Some(scene::RGBAColor(250, 240, 230, 255)), + "magenta" => Some(scene::RGBAColor(255, 0, 255, 255)), + "maroon" => Some(scene::RGBAColor(128, 0, 0, 255)), + "mediumaquamarine" => Some(scene::RGBAColor(102, 205, 170, 255)), + "mediumblue" => Some(scene::RGBAColor(0, 0, 205, 255)), + "mediumorchid" => Some(scene::RGBAColor(186, 85, 211, 255)), + "mediumpurple" => Some(scene::RGBAColor(147, 112, 219, 255)), + "mediumseagreen" => Some(scene::RGBAColor(60, 179, 113, 255)), + "mediumslateblue" => Some(scene::RGBAColor(123, 104, 238, 255)), + "mediumspringgreen" => Some(scene::RGBAColor(0, 250, 154, 255)), + "mediumturquoise" => Some(scene::RGBAColor(72, 209, 204, 255)), + "mediumvioletred" => Some(scene::RGBAColor(199, 21, 133, 255)), + "midnightblue" => Some(scene::RGBAColor(25, 25, 112, 255)), + "mintcream" => Some(scene::RGBAColor(245, 255, 250, 255)), + "mistyrose" => Some(scene::RGBAColor(255, 228, 225, 255)), + "moccasin" => Some(scene::RGBAColor(255, 228, 181, 255)), + "navajowhite" => Some(scene::RGBAColor(255, 222, 173, 255)), + "navy" => Some(scene::RGBAColor(0, 0, 128, 255)), + "oldlace" => Some(scene::RGBAColor(253, 245, 230, 255)), + "olive" => Some(scene::RGBAColor(128, 128, 0, 255)), + "olivedrab" => Some(scene::RGBAColor(107, 142, 35, 255)), + "orange" => Some(scene::RGBAColor(255, 165, 0, 255)), + "orangered" => Some(scene::RGBAColor(255, 69, 0, 255)), + "orchid" => Some(scene::RGBAColor(218, 112, 214, 255)), + "palegoldenrod" => Some(scene::RGBAColor(238, 232, 170, 255)), + "palegreen" => Some(scene::RGBAColor(152, 251, 152, 255)), + "paleturquoise" => Some(scene::RGBAColor(175, 238, 238, 255)), + "palevioletred" => Some(scene::RGBAColor(219, 112, 147, 255)), + "papayawhip" => Some(scene::RGBAColor(255, 239, 213, 255)), + "peachpuff" => Some(scene::RGBAColor(255, 218, 185, 255)), + "peru" => Some(scene::RGBAColor(205, 133, 63, 255)), + "pink" => Some(scene::RGBAColor(255, 192, 203, 255)), + "plum" => Some(scene::RGBAColor(221, 160, 221, 255)), + "powderblue" => Some(scene::RGBAColor(176, 224, 230, 255)), + "purple" => Some(scene::RGBAColor(128, 0, 128, 255)), + "rebeccapurple" => Some(scene::RGBAColor(102, 51, 153, 255)), + "red" => Some(scene::RGBAColor(255, 0, 0, 255)), + "rosybrown" => Some(scene::RGBAColor(188, 143, 143, 255)), + "royalblue" => Some(scene::RGBAColor(65, 105, 225, 255)), + "saddlebrown" => Some(scene::RGBAColor(139, 69, 19, 255)), + "salmon" => Some(scene::RGBAColor(250, 128, 114, 255)), + "sandybrown" => Some(scene::RGBAColor(244, 164, 96, 255)), + "seagreen" => Some(scene::RGBAColor(46, 139, 87, 255)), + "seashell" => Some(scene::RGBAColor(255, 245, 238, 255)), + "sienna" => Some(scene::RGBAColor(160, 82, 45, 255)), + "silver" => Some(scene::RGBAColor(192, 192, 192, 255)), + "skyblue" => Some(scene::RGBAColor(135, 206, 235, 255)), + "slateblue" => Some(scene::RGBAColor(106, 90, 205, 255)), + "slategray" => Some(scene::RGBAColor(112, 128, 144, 255)), + "slategrey" => Some(scene::RGBAColor(112, 128, 144, 255)), + "snow" => Some(scene::RGBAColor(255, 250, 250, 255)), + "springgreen" => Some(scene::RGBAColor(0, 255, 127, 255)), + "steelblue" => Some(scene::RGBAColor(70, 130, 180, 255)), + "tan" => Some(scene::RGBAColor(210, 180, 140, 255)), + "teal" => Some(scene::RGBAColor(0, 128, 128, 255)), + "thistle" => Some(scene::RGBAColor(216, 191, 216, 255)), + "tomato" => Some(scene::RGBAColor(255, 99, 71, 255)), + "turquoise" => Some(scene::RGBAColor(64, 224, 208, 255)), + "violet" => Some(scene::RGBAColor(238, 130, 238, 255)), + "wheat" => Some(scene::RGBAColor(245, 222, 179, 255)), + "white" => Some(scene::RGBAColor(255, 255, 255, 255)), + "whitesmoke" => Some(scene::RGBAColor(245, 245, 245, 255)), + "yellow" => Some(scene::RGBAColor(255, 255, 0, 255)), + "yellowgreen" => Some(scene::RGBAColor(154, 205, 50, 255)), + _ => None, + } +} diff --git a/compositor_api/src/types/from_component.rs b/compositor_api/src/types/from_component.rs index 5bc2ad9fb..339f115c0 100644 --- a/compositor_api/src/types/from_component.rs +++ b/compositor_api/src/types/from_component.rs @@ -98,14 +98,14 @@ impl TryFrom for scene::ViewComponent { position, overflow, background_color: view - .background_color_rgba + .background_color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(0, 0, 0, 0)))?, transition: view.transition.map(TryInto::try_into).transpose()?, border_radius: BorderRadius::new_with_radius(view.border_radius.unwrap_or(0.0)), border_width: view.border_width.unwrap_or(0.0), border_color: view - .border_color_rgba + .border_color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(0, 0, 0, 0)))?, box_shadow: view @@ -181,7 +181,7 @@ impl TryFrom for scene::RescalerComponent { border_radius: BorderRadius::new_with_radius(rescaler.border_radius.unwrap_or(0.0)), border_width: rescaler.border_width.unwrap_or(0.0), border_color: rescaler - .border_color_rgba + .border_color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(0, 0, 0, 0)))?, box_shadow: rescaler @@ -295,7 +295,7 @@ impl TryFrom for scene::TextComponent { dimensions, line_height: text.line_height.unwrap_or(text.font_size), color: text - .color_rgba + .color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(255, 255, 255, 255)))?, font_family: text.font_family.unwrap_or_else(|| Arc::from("Verdana")), @@ -304,7 +304,7 @@ impl TryFrom for scene::TextComponent { wrap, weight, background_color: text - .background_color_rgba + .background_color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(0, 0, 0, 0)))?, }; @@ -345,7 +345,7 @@ impl TryFrom for scene::TilesComponent { height: tiles.height, background_color: tiles - .background_color_rgba + .background_color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(0, 0, 0, 0)))?, tile_aspect_ratio: tiles @@ -374,7 +374,7 @@ impl TryFrom for scene::BoxShadow { offset_y: value.offset_y.unwrap_or(0.0), blur_radius: value.blur_radius.unwrap_or(0.0), color: value - .color_rgba + .color .map(TryInto::try_into) .unwrap_or(Ok(scene::RGBAColor(255, 255, 255, 255)))?, }) diff --git a/compositor_api/src/types/from_util.rs b/compositor_api/src/types/from_util.rs index 85a0109cb..5e54ec7c7 100644 --- a/compositor_api/src/types/from_util.rs +++ b/compositor_api/src/types/from_util.rs @@ -127,73 +127,6 @@ impl TryFrom for (u32, u32) { } } -impl TryFrom for scene::RGBColor { - type Error = TypeError; - - fn try_from(value: RGBColor) -> std::result::Result { - let s = &value.0; - if s.len() != 7 { - return Err(TypeError::new( - "Invalid format. Color has to be in #RRGGBB format.", - )); - } - if !s.starts_with('#') { - return Err(TypeError::new( - "Invalid format. Color definition has to start with #.", - )); - } - let (r, g, b) = (&s[1..3], &s[3..5], &s[5..7]); - - fn parse_color_channel(value: &str) -> Result { - u8::from_str_radix(value, 16).map_err(|_err| { - TypeError::new( - "Invalid format. Color representation is not a valid hexadecimal number.", - ) - }) - } - - Ok(Self( - parse_color_channel(r)?, - parse_color_channel(g)?, - parse_color_channel(b)?, - )) - } -} - -impl TryFrom for scene::RGBAColor { - type Error = TypeError; - - fn try_from(value: RGBAColor) -> std::result::Result { - let s = &value.0; - if s.len() != 9 { - return Err(TypeError::new( - "Invalid format. Color has to be in #RRGGBBAA format.", - )); - } - if !s.starts_with('#') { - return Err(TypeError::new( - "Invalid format. Color definition has to start with #.", - )); - } - let (r, g, b, a) = (&s[1..3], &s[3..5], &s[5..7], &s[7..9]); - - fn parse_color_channel(value: &str) -> Result { - u8::from_str_radix(value, 16).map_err(|_err| { - TypeError::new( - "Invalid format. Color representation is not a valid hexadecimal number.", - ) - }) - } - - Ok(Self( - parse_color_channel(r)?, - parse_color_channel(g)?, - parse_color_channel(b)?, - parse_color_channel(a)?, - )) - } -} - #[cfg(not(target_arch = "wasm32"))] impl TryFrom for compositor_pipeline::pipeline::rtp::RequestedPort { type Error = TypeError; diff --git a/compositor_api/src/types/from_util_test.rs b/compositor_api/src/types/from_util_test.rs index d28e7e5eb..8815a5328 100644 --- a/compositor_api/src/types/from_util_test.rs +++ b/compositor_api/src/types/from_util_test.rs @@ -1,35 +1,6 @@ use compositor_render::scene; -use crate::types::{ - util::{RGBAColor, RGBColor}, - TypeError, -}; - -#[test] -fn test_rgb_deserialization() { - fn test_case(color: &str, expected: Result) { - assert_eq!( - scene::RGBColor::try_from(RGBColor(color.to_string())), - expected - ); - } - test_case("#000000", Ok(scene::RGBColor(0, 0, 0))); - test_case("#010203", Ok(scene::RGBColor(1, 2, 3))); - test_case("#01FF03", Ok(scene::RGBColor(1, 255, 3))); - test_case("#FFffFF", Ok(scene::RGBColor(255, 255, 255))); - test_case( - "#00000G", - Err(TypeError::new( - "Invalid format. Color representation is not a valid hexadecimal number.", - )), - ); - test_case( - "#000", - Err(TypeError::new( - "Invalid format. Color has to be in #RRGGBB format.", - )), - ); -} +use crate::types::{util::RGBAColor, TypeError}; #[test] fn test_rgba_deserialization() { @@ -46,13 +17,13 @@ fn test_rgba_deserialization() { test_case( "#0000000G", Err(TypeError::new( - "Invalid format. Color representation is not a valid hexadecimal number.", + "Invalid format. Color representation is not a valid number.", )), ); test_case( "#000", Err(TypeError::new( - "Invalid format. Color has to be in #RRGGBBAA format.", + "Invalid format. Color has to be in #RRGGBB or #RRGGBBAA format.", )), ); } diff --git a/compositor_api/src/types/util.rs b/compositor_api/src/types/util.rs index 4b082e241..2da3467d4 100644 --- a/compositor_api/src/types/util.rs +++ b/compositor_api/src/types/util.rs @@ -63,9 +63,6 @@ pub enum Framerate { U32(u32), } -#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)] -pub struct RGBColor(pub String); - #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)] pub struct RGBAColor(pub String); diff --git a/compositor_pipeline/src/pipeline/encoder/fdk_aac.rs b/compositor_pipeline/src/pipeline/encoder/fdk_aac.rs index c8f673e45..47890a1a3 100644 --- a/compositor_pipeline/src/pipeline/encoder/fdk_aac.rs +++ b/compositor_pipeline/src/pipeline/encoder/fdk_aac.rs @@ -14,7 +14,7 @@ use tracing::{debug, error, span, Level}; use crate::{ audio_mixer::{AudioChannels, AudioSamples, OutputSamples}, error::EncoderInitError, - pipeline::{AudioCodec, EncodedChunk, EncodedChunkKind, EncoderOutputEvent}, + pipeline::{types::IsKeyframe, AudioCodec, EncodedChunk, EncodedChunkKind, EncoderOutputEvent}, queue::PipelineEvent, }; @@ -266,6 +266,7 @@ impl AacEncoderInner { data: output.freeze(), pts, dts: None, + is_keyframe: IsKeyframe::NoKeyframes, kind: EncodedChunkKind::Audio(AudioCodec::Aac), })), } diff --git a/compositor_pipeline/src/pipeline/encoder/ffmpeg_h264.rs b/compositor_pipeline/src/pipeline/encoder/ffmpeg_h264.rs index ad3eb22d1..e45346a19 100644 --- a/compositor_pipeline/src/pipeline/encoder/ffmpeg_h264.rs +++ b/compositor_pipeline/src/pipeline/encoder/ffmpeg_h264.rs @@ -13,7 +13,8 @@ use tracing::{debug, error, span, trace, warn, Level}; use crate::{ error::EncoderInitError, pipeline::types::{ - ChunkFromFfmpegError, EncodedChunk, EncodedChunkKind, EncoderOutputEvent, VideoCodec, + ChunkFromFfmpegError, EncodedChunk, EncodedChunkKind, EncoderOutputEvent, IsKeyframe, + VideoCodec, }, queue::PipelineEvent, }; @@ -389,6 +390,11 @@ fn encoded_chunk_from_av_packet( .map(rescale) .ok_or(ChunkFromFfmpegError::NoPts)?, dts: value.dts().map(rescale), + is_keyframe: if value.flags().contains(ffmpeg_next::packet::Flags::KEY) { + IsKeyframe::Yes + } else { + IsKeyframe::No + }, kind, }) } diff --git a/compositor_pipeline/src/pipeline/encoder/opus.rs b/compositor_pipeline/src/pipeline/encoder/opus.rs index 97d333189..5ed1dc9d2 100644 --- a/compositor_pipeline/src/pipeline/encoder/opus.rs +++ b/compositor_pipeline/src/pipeline/encoder/opus.rs @@ -6,7 +6,7 @@ use crate::{ audio_mixer::{AudioChannels, AudioSamples, OutputSamples}, error::EncoderInitError, pipeline::{ - types::{EncodedChunk, EncodedChunkKind, EncoderOutputEvent}, + types::{EncodedChunk, EncodedChunkKind, EncoderOutputEvent, IsKeyframe}, AudioCodec, }, queue::PipelineEvent, @@ -94,6 +94,7 @@ fn run_encoder_thread( data, pts: batch.start_pts, dts: None, + is_keyframe: IsKeyframe::NoKeyframes, kind: EncodedChunkKind::Audio(AudioCodec::Opus), }; diff --git a/compositor_pipeline/src/pipeline/input/mp4/reader.rs b/compositor_pipeline/src/pipeline/input/mp4/reader.rs index 06185d920..162f79fc3 100644 --- a/compositor_pipeline/src/pipeline/input/mp4/reader.rs +++ b/compositor_pipeline/src/pipeline/input/mp4/reader.rs @@ -11,7 +11,7 @@ use mp4::Mp4Sample; use tracing::warn; use crate::pipeline::{ - types::{EncodedChunk, EncodedChunkKind}, + types::{EncodedChunk, EncodedChunkKind, IsKeyframe}, AudioCodec, VideoCodec, }; @@ -235,6 +235,10 @@ impl TrackChunks<'_, Reader> { data, pts, dts: Some(dts), + is_keyframe: match self.track.decoder_options { + DecoderOptions::H264 => IsKeyframe::Unknown, + DecoderOptions::Aac(_) => IsKeyframe::NoKeyframes, + }, kind: match self.track.decoder_options { DecoderOptions::H264 => EncodedChunkKind::Video(VideoCodec::H264), DecoderOptions::Aac(_) => EncodedChunkKind::Audio(AudioCodec::Aac), diff --git a/compositor_pipeline/src/pipeline/input/rtp/depayloader.rs b/compositor_pipeline/src/pipeline/input/rtp/depayloader.rs index 2b3858555..0b5ff0a05 100644 --- a/compositor_pipeline/src/pipeline/input/rtp/depayloader.rs +++ b/compositor_pipeline/src/pipeline/input/rtp/depayloader.rs @@ -10,7 +10,7 @@ use rtp::{ use crate::pipeline::{ decoder::{self, AacDecoderOptions}, rtp::{AUDIO_PAYLOAD_TYPE, VIDEO_PAYLOAD_TYPE}, - types::{AudioCodec, EncodedChunk, EncodedChunkKind, VideoCodec}, + types::{AudioCodec, EncodedChunk, EncodedChunkKind, IsKeyframe, VideoCodec}, VideoDecoder, }; @@ -126,6 +126,7 @@ impl VideoDepayloader { data: mem::take(buffer).concat().into(), pts: Duration::from_secs_f64(timestamp as f64 / 90000.0), dts: None, + is_keyframe: IsKeyframe::Unknown, kind, }; @@ -194,6 +195,7 @@ impl AudioDepayloader { data: opus_packet, pts: Duration::from_secs_f64(timestamp as f64 / 48000.0), dts: None, + is_keyframe: IsKeyframe::NoKeyframes, kind, }]) } diff --git a/compositor_pipeline/src/pipeline/input/rtp/depayloader/aac.rs b/compositor_pipeline/src/pipeline/input/rtp/depayloader/aac.rs index 61a238452..7158fe0f7 100644 --- a/compositor_pipeline/src/pipeline/input/rtp/depayloader/aac.rs +++ b/compositor_pipeline/src/pipeline/input/rtp/depayloader/aac.rs @@ -4,7 +4,7 @@ use bytes::{Buf, BytesMut}; use crate::pipeline::{ decoder::AacDepayloaderMode, - types::{EncodedChunk, EncodedChunkKind}, + types::{EncodedChunk, EncodedChunkKind, IsKeyframe}, AudioCodec, }; @@ -269,6 +269,7 @@ impl AacDepayloader { pts, data: payload, dts: None, + is_keyframe: IsKeyframe::NoKeyframes, kind: EncodedChunkKind::Audio(AudioCodec::Aac), }); } diff --git a/compositor_pipeline/src/pipeline/output/mp4.rs b/compositor_pipeline/src/pipeline/output/mp4.rs index 147eae348..7a696044c 100644 --- a/compositor_pipeline/src/pipeline/output/mp4.rs +++ b/compositor_pipeline/src/pipeline/output/mp4.rs @@ -10,7 +10,10 @@ use crate::{ audio_mixer::AudioChannels, error::OutputInitError, event::Event, - pipeline::{EncodedChunk, EncodedChunkKind, EncoderOutputEvent, PipelineCtx, VideoCodec}, + pipeline::{ + types::IsKeyframe, EncodedChunk, EncodedChunkKind, EncoderOutputEvent, PipelineCtx, + VideoCodec, + }, }; #[derive(Debug, Clone)] @@ -183,8 +186,10 @@ fn init_ffmpeg_output( }) .transpose()?; + let ffmpeg_options = ffmpeg::Dictionary::from_iter(&[("movflags", "faststart")]); + output_ctx - .write_header() + .write_header_with(ffmpeg_options) .map_err(OutputInitError::FfmpegMp4Error)?; Ok((output_ctx, video_stream, audio_stream)) @@ -287,6 +292,12 @@ fn create_packet( packet.set_time_base(ffmpeg::Rational::new(1, stream_state.time_base as i32)); packet.set_stream(stream_state.id); + match chunk.is_keyframe { + IsKeyframe::Yes => packet.set_flags(ffmpeg::packet::Flags::KEY), + IsKeyframe::Unknown => warn!("The MP4 output received an encoded chunk with is_keyframe set to Unknown. This output needs this information to produce correct mp4s."), + IsKeyframe::NoKeyframes | IsKeyframe::No => {}, + } + Some(packet) } diff --git a/compositor_pipeline/src/pipeline/types.rs b/compositor_pipeline/src/pipeline/types.rs index a2f26c613..0c0919cd0 100644 --- a/compositor_pipeline/src/pipeline/types.rs +++ b/compositor_pipeline/src/pipeline/types.rs @@ -17,9 +17,21 @@ pub struct EncodedChunk { pub data: Bytes, pub pts: Duration, pub dts: Option, + pub is_keyframe: IsKeyframe, pub kind: EncodedChunkKind, } +pub enum IsKeyframe { + /// this is a keyframe + Yes, + /// this is not a keyframe + No, + /// it's unknown whether this frame is a keyframe or not + Unknown, + /// the codec this chunk is encoded in does not have keyframes at all + NoKeyframes, +} + #[derive(Debug)] pub enum EncoderOutputEvent { Data(EncodedChunk), diff --git a/generate/src/bin/generate_docs_example_inputs.rs b/generate/src/bin/generate_docs_example_inputs.rs index ec13a01e9..3fd533e96 100644 --- a/generate/src/bin/generate_docs_example_inputs.rs +++ b/generate/src/bin/generate_docs_example_inputs.rs @@ -91,7 +91,7 @@ fn scene(text: &str, rgba_color: &str, pts: Duration) -> serde_json::Value { json!({ "root": { "type": "view", - "background_color_rgba": rgba_color, + "background_color": rgba_color, "direction": "column", "children": [ { "type": "view" }, diff --git a/generate/src/bin/generate_docs_examples/basic_layouts.rs b/generate/src/bin/generate_docs_examples/basic_layouts.rs index c2d0abf3a..37bbb6cf1 100644 --- a/generate/src/bin/generate_docs_examples/basic_layouts.rs +++ b/generate/src/bin/generate_docs_examples/basic_layouts.rs @@ -11,14 +11,14 @@ pub(super) fn generate_basic_layouts_guide() -> Result<()> { "basic_layouts_1.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", }), )?; generate_scene( "basic_layouts_2.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "input_stream", "input_id": "input_1" }, ] @@ -28,7 +28,7 @@ pub(super) fn generate_basic_layouts_guide() -> Result<()> { "basic_layouts_3.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "rescaler", @@ -41,7 +41,7 @@ pub(super) fn generate_basic_layouts_guide() -> Result<()> { "basic_layouts_4.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "rescaler", @@ -58,7 +58,7 @@ pub(super) fn generate_basic_layouts_guide() -> Result<()> { "basic_layouts_5.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "rescaler", diff --git a/generate/src/bin/generate_docs_examples/quick_start.rs b/generate/src/bin/generate_docs_examples/quick_start.rs index 341331d58..d6cf9a6a5 100644 --- a/generate/src/bin/generate_docs_examples/quick_start.rs +++ b/generate/src/bin/generate_docs_examples/quick_start.rs @@ -11,7 +11,7 @@ pub(super) fn generate_quick_start_guide() -> Result<()> { "quick_start_1.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [] }), )?; @@ -19,7 +19,7 @@ pub(super) fn generate_quick_start_guide() -> Result<()> { "quick_start_2.webp", json!({ "type": "tiles", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "input_stream", "input_id": "input_1" }, { "type": "input_stream", "input_id": "input_2" }, diff --git a/generate/src/bin/generate_docs_examples/transition.rs b/generate/src/bin/generate_docs_examples/transition.rs index 29cf714ff..e8fab434f 100644 --- a/generate/src/bin/generate_docs_examples/transition.rs +++ b/generate/src/bin/generate_docs_examples/transition.rs @@ -216,7 +216,7 @@ fn scene(inputs: Vec<&str>) -> serde_json::Value { "id": "tile", "children": inputs, "margin": 20, - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "transition": { "duration_ms": 500, "easing_function": { diff --git a/generate/src/bin/generate_docs_examples/view_transitions.rs b/generate/src/bin/generate_docs_examples/view_transitions.rs index 495df5142..f6666e0b0 100644 --- a/generate/src/bin/generate_docs_examples/view_transitions.rs +++ b/generate/src/bin/generate_docs_examples/view_transitions.rs @@ -11,7 +11,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { "view_transition_1.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -23,7 +23,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { }), json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -42,7 +42,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { "view_transition_2.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -59,7 +59,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { }), json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -82,7 +82,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { "view_transition_3.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -94,7 +94,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { }), json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -113,7 +113,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { "view_transition_4.webp", json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", @@ -143,7 +143,7 @@ pub(super) fn generate_view_transition_guide() -> Result<()> { }), json!({ "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "id": "rescaler_1", diff --git a/generate/src/bin/generate_playground_inputs.rs b/generate/src/bin/generate_playground_inputs.rs index c95aadace..af026a84a 100644 --- a/generate/src/bin/generate_playground_inputs.rs +++ b/generate/src/bin/generate_playground_inputs.rs @@ -159,7 +159,7 @@ fn scene(text: &str, rgba_color: &str, resolution: Resolution) -> serde_json::Va json!({ "root": { "type": "view", - "background_color_rgba": rgba_color, + "background_color": rgba_color, "direction": "column", "children": [ { "type": "view" }, diff --git a/integration_tests/examples/decklink.rs b/integration_tests/examples/decklink.rs index 357d9672d..b04f3d176 100644 --- a/integration_tests/examples/decklink.rs +++ b/integration_tests/examples/decklink.rs @@ -48,7 +48,7 @@ fn client_code() -> Result<()> { "initial": { "root": { "type": "view", - "background_color_rgba": "#4d4d4dff", + "background_color": "#4d4d4dff", "children": [ { "type": "rescaler", diff --git a/integration_tests/examples/image.rs b/integration_tests/examples/image.rs index 408a92957..ff93a38ff 100644 --- a/integration_tests/examples/image.rs +++ b/integration_tests/examples/image.rs @@ -55,7 +55,7 @@ fn client_code() -> Result<()> { let new_image = |image_id, label| { json!({ "type": "view", - "background_color_rgba": "#0000FFFF", + "background_color": "#0000FFFF", "children": [ { "type": "rescaler", diff --git a/integration_tests/examples/mp4.rs b/integration_tests/examples/mp4_output.rs similarity index 100% rename from integration_tests/examples/mp4.rs rename to integration_tests/examples/mp4_output.rs diff --git a/integration_tests/examples/rescaler_border_transition.rs b/integration_tests/examples/rescaler_border_transition.rs index 5e38a5b2e..b4e0f5ec6 100644 --- a/integration_tests/examples/rescaler_border_transition.rs +++ b/integration_tests/examples/rescaler_border_transition.rs @@ -48,7 +48,7 @@ fn client_code() -> Result<()> { let scene1 = json!({ "type": "view", - "background_color_rgba": "#42daf5ff", + "background_color": "#42daf5ff", "children": [ { "type": "rescaler", @@ -58,13 +58,13 @@ fn client_code() -> Result<()> { "top": 0.0, "right": 0.0, "mode": "fill", - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_y": 40, "offset_x": 0, "blur_radius": 40, - "color_rgba": "#00000088", + "color": "#00000088", } ], "child": { @@ -77,7 +77,7 @@ fn client_code() -> Result<()> { let scene2 = json!({ "type": "view", - "background_color_rgba": "#42daf5ff", + "background_color": "#42daf5ff", "children": [ { "type": "rescaler", @@ -89,13 +89,13 @@ fn client_code() -> Result<()> { "mode": "fill", "border_radius": 50, "border_width": 15, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_y": 40, "offset_x": 0, "blur_radius": 40, - "color_rgba": "#00000088", + "color": "#00000088", } ], "transition": { diff --git a/integration_tests/examples/text.rs b/integration_tests/examples/text.rs index 87fc3bbc7..dfd7c3695 100644 --- a/integration_tests/examples/text.rs +++ b/integration_tests/examples/text.rs @@ -45,7 +45,7 @@ fn client_code() -> Result<()> { "font_family": "Comic Sans MS", "align": "center", "wrap": "word", - "background_color_rgba": "#00800000", + "background_color": "#00800000", "weight": "bold", "width": VIDEO_RESOLUTION.width, "height": VIDEO_RESOLUTION.height, diff --git a/integration_tests/examples/tiles.rs b/integration_tests/examples/tiles.rs index 6998d5be7..cfbb12600 100644 --- a/integration_tests/examples/tiles.rs +++ b/integration_tests/examples/tiles.rs @@ -47,7 +47,7 @@ fn client_code() -> Result<()> { "type": "tiles", "id": "tile", "padding": 5, - "background_color_rgba": "#444444FF", + "background_color": "#444444FF", "children": children, "transition": { "duration_ms": 700, diff --git a/integration_tests/examples/transition.rs b/integration_tests/examples/transition.rs index 511473e94..a396e60b7 100644 --- a/integration_tests/examples/transition.rs +++ b/integration_tests/examples/transition.rs @@ -53,7 +53,7 @@ fn client_code() -> Result<()> { let scene1 = json!({ "type": "view", - "background_color_rgba": "#444444FF", + "background_color": "#444444FF", "children": [ { "type": "view", @@ -80,7 +80,7 @@ fn client_code() -> Result<()> { let scene2 = json!({ "type": "view", - "background_color_rgba": "#444444FF", + "background_color": "#444444FF", "children": [ { "type": "view", @@ -114,7 +114,7 @@ fn client_code() -> Result<()> { let scene3 = json!({ "type": "view", - "background_color_rgba": "#444444FF", + "background_color": "#444444FF", "children": [ { "type": "view", diff --git a/integration_tests/examples/view_border_transition.rs b/integration_tests/examples/view_border_transition.rs index 72ed6f2fd..2244fa0cc 100644 --- a/integration_tests/examples/view_border_transition.rs +++ b/integration_tests/examples/view_border_transition.rs @@ -48,7 +48,7 @@ fn client_code() -> Result<()> { let scene1 = json!({ "type": "view", - "background_color_rgba": "#42daf5ff", + "background_color": "#42daf5ff", "children": [ { "type": "view", @@ -57,14 +57,14 @@ fn client_code() -> Result<()> { "height": VIDEO_RESOLUTION.height, "top": 0.0, "right": 0.0, - "background_color_rgba": "#0000FFFF", - "border_color_rgba": "#FFFFFFFF", + "background_color": "#0000FFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_y": 40, "offset_x": 0, "blur_radius": 40, - "color_rgba": "#00000088", + "color": "#00000088", } ], "children": [ @@ -83,7 +83,7 @@ fn client_code() -> Result<()> { let scene2 = json!({ "type": "view", - "background_color_rgba": "#42daf5ff", + "background_color": "#42daf5ff", "children": [ { "type": "view", @@ -94,14 +94,14 @@ fn client_code() -> Result<()> { "right": (VIDEO_RESOLUTION.width as f32 - 330.0) / 2.0, "border_radius": 50, "border_width": 15, - "background_color_rgba": "#0000FFFF", - "border_color_rgba": "#FFFFFFFF", + "background_color": "#0000FFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_y": 40, "offset_x": 0, "blur_radius": 40, - "color_rgba": "#00000088", + "color": "#00000088", } ], "transition": { diff --git a/integration_tests/examples/vulkan.rs b/integration_tests/examples/vulkan.rs index 6e9ed6c11..8dc5dbfc0 100644 --- a/integration_tests/examples/vulkan.rs +++ b/integration_tests/examples/vulkan.rs @@ -58,7 +58,7 @@ fn client_code() -> Result<()> { "type": "tiles", "id": "tile", "padding": 5, - "background_color_rgba": "#444444FF", + "background_color": "#444444FF", "children": children, }); diff --git a/integration_tests/src/tests/required_inputs.rs b/integration_tests/src/tests/required_inputs.rs index c6a167027..e4a137340 100644 --- a/integration_tests/src/tests/required_inputs.rs +++ b/integration_tests/src/tests/required_inputs.rs @@ -39,7 +39,7 @@ pub fn required_video_inputs_no_offset() -> Result<()> { "root": { "type": "tiles", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "children": [ { "type": "input_stream", @@ -156,7 +156,7 @@ pub fn required_video_inputs_with_offset() -> Result<()> { "root": { "type": "tiles", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "children": [ { "type": "input_stream", @@ -546,7 +546,7 @@ pub fn optional_inputs_no_offset_flaky() -> Result<()> { "root": { "type": "tiles", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "children": [ { "type": "input_stream", diff --git a/integration_tests/src/tests/schedule_update.rs b/integration_tests/src/tests/schedule_update.rs index 72becedaa..e8bb30ed8 100644 --- a/integration_tests/src/tests/schedule_update.rs +++ b/integration_tests/src/tests/schedule_update.rs @@ -39,7 +39,7 @@ pub fn schedule_update() -> Result<()> { "type": "tiles", "id": "tiles_1", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "transition": { "duration_ms": 500, "easing_function": { @@ -66,7 +66,7 @@ pub fn schedule_update() -> Result<()> { "type": "tiles", "id": "tiles_1", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "transition": { "duration_ms": 500, "easing_function": { diff --git a/integration_tests/src/tests/unregistering.rs b/integration_tests/src/tests/unregistering.rs index 4dd71ffdf..405bc7b1b 100644 --- a/integration_tests/src/tests/unregistering.rs +++ b/integration_tests/src/tests/unregistering.rs @@ -98,7 +98,7 @@ fn register_output_with_initial_scene(instance: &CompositorInstance, port: u16) "root": { "type": "tiles", "padding": 3, - "background_color_rgba": "#DDDDDDFF", + "background_color": "#DDDDDDFF", "children": [ { "type": "input_stream", diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json index 63b743f06..bb0f320cf 100644 --- a/schemas/scene.schema.json +++ b/schemas/scene.schema.json @@ -204,7 +204,7 @@ } ] }, - "background_color_rgba": { + "background_color": { "description": "(**default=`\"#00000000\"`**) Background color in a `\"#RRGGBBAA\"` format.", "anyOf": [ { @@ -231,7 +231,7 @@ ], "format": "float" }, - "border_color_rgba": { + "border_color": { "description": "(**default=`\"#00000000\"`**) Border color in a `\"#RRGGBBAA\"` format.", "anyOf": [ { @@ -475,7 +475,7 @@ ], "format": "float" }, - "color_rgba": { + "color": { "description": "(**default=`\"#FFFFFFFF\"`**) Font color in `#RRGGBBAA` format.", "anyOf": [ { @@ -486,7 +486,7 @@ } ] }, - "background_color_rgba": { + "background_color": { "description": "(**default=`\"#00000000\"`**) Background color in `#RRGGBBAA` format.", "anyOf": [ { @@ -600,7 +600,7 @@ ], "format": "float" }, - "background_color_rgba": { + "background_color": { "description": "(**default=`\"#00000000\"`**) Background color in a `\"#RRGGBBAA\"` format.", "anyOf": [ { @@ -829,7 +829,7 @@ ], "format": "float" }, - "border_color_rgba": { + "border_color": { "description": "(**default=`\"#00000000\"`**) Border color in a `\"#RRGGBBAA\"` format.", "anyOf": [ { @@ -1005,7 +1005,7 @@ ], "format": "float" }, - "color_rgba": { + "color": { "anyOf": [ { "$ref": "#/definitions/RGBAColor" diff --git a/snapshot_tests/rescaler/border_radius.scene.json b/snapshot_tests/rescaler/border_radius.scene.json index d870231f7..11a6cfc71 100644 --- a/snapshot_tests/rescaler/border_radius.scene.json +++ b/snapshot_tests/rescaler/border_radius.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -13,7 +13,7 @@ "border_radius": 50, "child": { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } } ] diff --git a/snapshot_tests/rescaler/border_radius_border_box_shadow.scene.json b/snapshot_tests/rescaler/border_radius_border_box_shadow.scene.json index 669378200..ad862bb29 100644 --- a/snapshot_tests/rescaler/border_radius_border_box_shadow.scene.json +++ b/snapshot_tests/rescaler/border_radius_border_box_shadow.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,18 +12,18 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } } ] diff --git a/snapshot_tests/rescaler/border_radius_border_box_shadow_rescaled.scene.json b/snapshot_tests/rescaler/border_radius_border_box_shadow_rescaled.scene.json index 0f1084b6d..32387c219 100644 --- a/snapshot_tests/rescaler/border_radius_border_box_shadow_rescaled.scene.json +++ b/snapshot_tests/rescaler/border_radius_border_box_shadow_rescaled.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -16,13 +16,13 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 20, "offset_y": 20, "blur_radius": 5, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { diff --git a/snapshot_tests/rescaler/border_radius_box_shadow.scene.json b/snapshot_tests/rescaler/border_radius_box_shadow.scene.json index e226b108c..06208b376 100644 --- a/snapshot_tests/rescaler/border_radius_box_shadow.scene.json +++ b/snapshot_tests/rescaler/border_radius_box_shadow.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -16,12 +16,12 @@ "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } } ] diff --git a/snapshot_tests/rescaler/border_radius_box_shadow_fill_input_stream.scene.json b/snapshot_tests/rescaler/border_radius_box_shadow_fill_input_stream.scene.json index 813b30e26..149c818e1 100644 --- a/snapshot_tests/rescaler/border_radius_box_shadow_fill_input_stream.scene.json +++ b/snapshot_tests/rescaler/border_radius_box_shadow_fill_input_stream.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,14 +12,14 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "mode": "fill", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { diff --git a/snapshot_tests/rescaler/border_radius_box_shadow_fit_input_stream.scene.json b/snapshot_tests/rescaler/border_radius_box_shadow_fit_input_stream.scene.json index ff9cc6e93..d49e95e63 100644 --- a/snapshot_tests/rescaler/border_radius_box_shadow_fit_input_stream.scene.json +++ b/snapshot_tests/rescaler/border_radius_box_shadow_fit_input_stream.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,14 +12,14 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "mode": "fit", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { diff --git a/snapshot_tests/rescaler/border_width.scene.json b/snapshot_tests/rescaler/border_width.scene.json index 8705ebd2c..1ddcad9d4 100644 --- a/snapshot_tests/rescaler/border_width.scene.json +++ b/snapshot_tests/rescaler/border_width.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -11,10 +11,10 @@ "width": 400, "height": 200, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "child": { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } } ] diff --git a/snapshot_tests/rescaler/box_shadow.scene.json b/snapshot_tests/rescaler/box_shadow.scene.json index dcfd76a9d..e2f43c3d1 100644 --- a/snapshot_tests/rescaler/box_shadow.scene.json +++ b/snapshot_tests/rescaler/box_shadow.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -15,12 +15,12 @@ "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "child": { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } } ] diff --git a/snapshot_tests/rescaler/fill_input_stream.scene.json b/snapshot_tests/rescaler/fill_input_stream.scene.json index 72ce45c51..9788aaa5a 100644 --- a/snapshot_tests/rescaler/fill_input_stream.scene.json +++ b/snapshot_tests/rescaler/fill_input_stream.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, 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 index 1553a462e..80c043e53 100644 --- a/snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json +++ b/snapshot_tests/rescaler/fill_input_stream_align_bottom_right.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, 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 index ff3a6df26..2db6e65f4 100644 --- a/snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json +++ b/snapshot_tests/rescaler/fill_input_stream_align_top_left.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, diff --git a/snapshot_tests/rescaler/fit_input_stream.scene.json b/snapshot_tests/rescaler/fit_input_stream.scene.json index a25d941d7..bee81b6a1 100644 --- a/snapshot_tests/rescaler/fit_input_stream.scene.json +++ b/snapshot_tests/rescaler/fit_input_stream.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, 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 index 95243f54c..ff78357fb 100644 --- a/snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json +++ b/snapshot_tests/rescaler/fit_input_stream_align_bottom_right.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, 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 index 45bd01381..7c678772f 100644 --- a/snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json +++ b/snapshot_tests/rescaler/fit_input_stream_align_top_left.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, diff --git a/snapshot_tests/rescaler/fit_view_with_known_height.scene.json b/snapshot_tests/rescaler/fit_view_with_known_height.scene.json index 118bd98fe..098f89b52 100644 --- a/snapshot_tests/rescaler/fit_view_with_known_height.scene.json +++ b/snapshot_tests/rescaler/fit_view_with_known_height.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, @@ -18,7 +18,7 @@ "height": 180, "child": { "type": "view", - "background_color_rgba": "#0000FFFF", + "background_color": "#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 index 90e00eb42..451be4ec3 100644 --- a/snapshot_tests/rescaler/fit_view_with_known_width.scene.json +++ b/snapshot_tests/rescaler/fit_view_with_known_width.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, @@ -18,7 +18,7 @@ "height": 180, "child": { "type": "view", - "background_color_rgba": "#0000FFFF", + "background_color": "#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 index 867b166ba..82072f305 100644 --- 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 @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 160, "height": 90 }, @@ -18,7 +18,7 @@ "height": 180, "child": { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } } ] diff --git a/snapshot_tests/rescaler/nested_border_width_radius.scene.json b/snapshot_tests/rescaler/nested_border_width_radius.scene.json index 342e39a0b..b01e1cb67 100644 --- a/snapshot_tests/rescaler/nested_border_width_radius.scene.json +++ b/snapshot_tests/rescaler/nested_border_width_radius.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,17 +12,17 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FF0000FF", + "border_color": "#FF0000FF", "child": { "type": "rescaler", "border_radius": 50, "border_width": 20, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "child": { "type": "rescaler", "border_radius": 50, "border_width": 20, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "mode": "fill", "child": { "type": "input_stream", diff --git a/snapshot_tests/rescaler/nested_border_width_radius_aligned.scene.json b/snapshot_tests/rescaler/nested_border_width_radius_aligned.scene.json index 03b0e49c2..598779fed 100644 --- a/snapshot_tests/rescaler/nested_border_width_radius_aligned.scene.json +++ b/snapshot_tests/rescaler/nested_border_width_radius_aligned.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,17 +12,17 @@ "height": 200, "border_radius": 80, "border_width": 20, - "border_color_rgba": "#FF0000FF", + "border_color": "#FF0000FF", "child": { "type": "rescaler", "border_radius": 60, "border_width": 20, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "child": { "type": "rescaler", "border_radius": 40, "border_width": 20, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "mode": "fill", "child": { "type": "input_stream", diff --git a/snapshot_tests/text/dimensions_fitted.scene.json b/snapshot_tests/text/dimensions_fitted.scene.json index 7dfdb090b..f56964247 100644 --- a/snapshot_tests/text/dimensions_fitted.scene.json +++ b/snapshot_tests/text/dimensions_fitted.scene.json @@ -9,7 +9,7 @@ "text": "Example text", "font_size": 100, "font_family": "Inter", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" } ] } diff --git a/snapshot_tests/text/red_text_on_blue_background.scene.json b/snapshot_tests/text/red_text_on_blue_background.scene.json index 3ce59d824..87574e92d 100644 --- a/snapshot_tests/text/red_text_on_blue_background.scene.json +++ b/snapshot_tests/text/red_text_on_blue_background.scene.json @@ -11,8 +11,8 @@ "font_family": "Inter", "align": "left", "wrap": "word", - "color_rgba": "#FF0000FF", - "background_color_rgba": "#0000FFFF", + "color": "#FF0000FF", + "background_color": "#0000FFFF", "width": 1000, "height": 500 } diff --git a/snapshot_tests/tiles/01_inputs.scene.json b/snapshot_tests/tiles/01_inputs.scene.json index 620bf4965..92ef44fe6 100644 --- a/snapshot_tests/tiles/01_inputs.scene.json +++ b/snapshot_tests/tiles/01_inputs.scene.json @@ -8,7 +8,7 @@ "input_id": "input_1" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/01_portrait_inputs.scene.json b/snapshot_tests/tiles/01_portrait_inputs.scene.json index 98d566db2..57ebd9326 100644 --- a/snapshot_tests/tiles/01_portrait_inputs.scene.json +++ b/snapshot_tests/tiles/01_portrait_inputs.scene.json @@ -9,7 +9,7 @@ "input_id": "input_1" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/02_inputs.scene.json b/snapshot_tests/tiles/02_inputs.scene.json index bd14043e7..6bb717dfa 100644 --- a/snapshot_tests/tiles/02_inputs.scene.json +++ b/snapshot_tests/tiles/02_inputs.scene.json @@ -12,7 +12,7 @@ "input_id": "input_2" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/02_portrait_inputs.scene.json b/snapshot_tests/tiles/02_portrait_inputs.scene.json index b3031fc01..ad5899bdf 100644 --- a/snapshot_tests/tiles/02_portrait_inputs.scene.json +++ b/snapshot_tests/tiles/02_portrait_inputs.scene.json @@ -13,7 +13,7 @@ "input_id": "input_2" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/03_inputs.scene.json b/snapshot_tests/tiles/03_inputs.scene.json index 149f7e968..32fb8d961 100644 --- a/snapshot_tests/tiles/03_inputs.scene.json +++ b/snapshot_tests/tiles/03_inputs.scene.json @@ -16,7 +16,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/03_portrait_inputs.scene.json b/snapshot_tests/tiles/03_portrait_inputs.scene.json index 7aa623bc1..08e61aa7e 100644 --- a/snapshot_tests/tiles/03_portrait_inputs.scene.json +++ b/snapshot_tests/tiles/03_portrait_inputs.scene.json @@ -17,7 +17,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/04_inputs.scene.json b/snapshot_tests/tiles/04_inputs.scene.json index a51a7ba76..b37363751 100644 --- a/snapshot_tests/tiles/04_inputs.scene.json +++ b/snapshot_tests/tiles/04_inputs.scene.json @@ -20,7 +20,7 @@ "input_id": "input_4" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/05_inputs.scene.json b/snapshot_tests/tiles/05_inputs.scene.json index 2a8bae306..fbcb05862 100644 --- a/snapshot_tests/tiles/05_inputs.scene.json +++ b/snapshot_tests/tiles/05_inputs.scene.json @@ -24,7 +24,7 @@ "input_id": "input_5" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/05_portrait_inputs.scene.json b/snapshot_tests/tiles/05_portrait_inputs.scene.json index ca1d69109..a55bc03bd 100644 --- a/snapshot_tests/tiles/05_portrait_inputs.scene.json +++ b/snapshot_tests/tiles/05_portrait_inputs.scene.json @@ -25,7 +25,7 @@ "input_id": "input_5" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/15_inputs.scene.json b/snapshot_tests/tiles/15_inputs.scene.json index 535749474..39eb908a4 100644 --- a/snapshot_tests/tiles/15_inputs.scene.json +++ b/snapshot_tests/tiles/15_inputs.scene.json @@ -64,7 +64,7 @@ "input_id": "input_15" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/15_portrait_inputs.scene.json b/snapshot_tests/tiles/15_portrait_inputs.scene.json index 51e7b79ac..0e340a46b 100644 --- a/snapshot_tests/tiles/15_portrait_inputs.scene.json +++ b/snapshot_tests/tiles/15_portrait_inputs.scene.json @@ -65,7 +65,7 @@ "input_id": "input_15" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/align_center_with_03_inputs.scene.json b/snapshot_tests/tiles/align_center_with_03_inputs.scene.json index 6de5070c6..5970eecec 100644 --- a/snapshot_tests/tiles/align_center_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/align_center_with_03_inputs.scene.json @@ -18,7 +18,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/align_top_left_with_03_inputs.scene.json b/snapshot_tests/tiles/align_top_left_with_03_inputs.scene.json index 0bbd886af..b3df032d9 100644 --- a/snapshot_tests/tiles/align_top_left_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/align_top_left_with_03_inputs.scene.json @@ -18,7 +18,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/align_with_margin_and_padding_with_03_inputs.scene.json b/snapshot_tests/tiles/align_with_margin_and_padding_with_03_inputs.scene.json index 5555ff819..905799d9a 100644 --- a/snapshot_tests/tiles/align_with_margin_and_padding_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/align_with_margin_and_padding_with_03_inputs.scene.json @@ -20,7 +20,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/margin_and_padding_with_03_inputs.scene.json b/snapshot_tests/tiles/margin_and_padding_with_03_inputs.scene.json index 369982620..4a3a1ea31 100644 --- a/snapshot_tests/tiles/margin_and_padding_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/margin_and_padding_with_03_inputs.scene.json @@ -18,7 +18,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/margin_with_03_inputs.scene.json b/snapshot_tests/tiles/margin_with_03_inputs.scene.json index fa8a38d83..aaaa8985e 100644 --- a/snapshot_tests/tiles/margin_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/margin_with_03_inputs.scene.json @@ -17,7 +17,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/padding_with_03_inputs.scene.json b/snapshot_tests/tiles/padding_with_03_inputs.scene.json index b2b47341c..cfb30d772 100644 --- a/snapshot_tests/tiles/padding_with_03_inputs.scene.json +++ b/snapshot_tests/tiles/padding_with_03_inputs.scene.json @@ -17,7 +17,7 @@ "input_id": "input_3" } ], - "background_color_rgba": "#333333FF" + "background_color": "#333333FF" } } } diff --git a/snapshot_tests/tiles/video_call_with_labels.scene.json b/snapshot_tests/tiles/video_call_with_labels.scene.json index c7ee7ea13..9c72b3303 100644 --- a/snapshot_tests/tiles/video_call_with_labels.scene.json +++ b/snapshot_tests/tiles/video_call_with_labels.scene.json @@ -6,7 +6,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#555555FF", + "background_color": "#555555FF", "children": [ { "type": "rescaler", @@ -29,8 +29,8 @@ "text": "InputStream 1", "font_size": 25, "align": "center", - "color_rgba": "#FFFFFFFF", - "background_color_rgba": "#FF0000FF" + "color": "#FFFFFFFF", + "background_color": "#FF0000FF" }, { "type": "view" @@ -41,7 +41,7 @@ }, { "type": "view", - "background_color_rgba": "#555555FF", + "background_color": "#555555FF", "children": [ { "type": "rescaler", @@ -64,8 +64,8 @@ "text": "InputStream 2", "font_size": 25, "align": "center", - "color_rgba": "#FFFFFFFF", - "background_color_rgba": "#FF0000FF" + "color": "#FFFFFFFF", + "background_color": "#FF0000FF" }, { "type": "view" @@ -76,7 +76,7 @@ }, { "type": "view", - "background_color_rgba": "#555555FF", + "background_color": "#555555FF", "children": [ { "type": "rescaler", @@ -99,8 +99,8 @@ "text": "InputStream 3", "font_size": 25, "align": "center", - "color_rgba": "#FFFFFFFF", - "background_color_rgba": "#FF0000FF" + "color": "#FFFFFFFF", + "background_color": "#FF0000FF" }, { "type": "view" diff --git a/snapshot_tests/tiles_transitions/end_tile_resize.scene.json b/snapshot_tests/tiles_transitions/end_tile_resize.scene.json index bba88aa41..78ed69b86 100644 --- a/snapshot_tests/tiles_transitions/end_tile_resize.scene.json +++ b/snapshot_tests/tiles_transitions/end_tile_resize.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#333333FF", + "background_color": "#333333FF", "width": 320, "height": 340, "bottom": 10, diff --git a/snapshot_tests/tiles_transitions/end_tile_resize_with_view_transition.scene.json b/snapshot_tests/tiles_transitions/end_tile_resize_with_view_transition.scene.json index f63e4d760..13e1612e4 100644 --- a/snapshot_tests/tiles_transitions/end_tile_resize_with_view_transition.scene.json +++ b/snapshot_tests/tiles_transitions/end_tile_resize_with_view_transition.scene.json @@ -6,7 +6,7 @@ { "type": "view", "id": "view", - "background_color_rgba": "#333333FF", + "background_color": "#333333FF", "width": 320, "height": 340, "bottom": 10, diff --git a/snapshot_tests/tiles_transitions/start_tile_resize.scene.json b/snapshot_tests/tiles_transitions/start_tile_resize.scene.json index 8a3709ac9..f5ff0c92f 100644 --- a/snapshot_tests/tiles_transitions/start_tile_resize.scene.json +++ b/snapshot_tests/tiles_transitions/start_tile_resize.scene.json @@ -6,7 +6,7 @@ { "type": "view", "id": "view", - "background_color_rgba": "#333333FF", + "background_color": "#333333FF", "width": 640, "height": 360, "bottom": 0, diff --git a/snapshot_tests/transition/change_rescaler_absolute_after_end.scene.json b/snapshot_tests/transition/change_rescaler_absolute_after_end.scene.json index f652448eb..81717118b 100644 --- a/snapshot_tests/transition/change_rescaler_absolute_after_end.scene.json +++ b/snapshot_tests/transition/change_rescaler_absolute_after_end.scene.json @@ -12,7 +12,7 @@ "right": 0, "child": { "type": "view", - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } } ] diff --git a/snapshot_tests/transition/change_rescaler_absolute_end.scene.json b/snapshot_tests/transition/change_rescaler_absolute_end.scene.json index 1a953650a..c774b518d 100644 --- a/snapshot_tests/transition/change_rescaler_absolute_end.scene.json +++ b/snapshot_tests/transition/change_rescaler_absolute_end.scene.json @@ -15,7 +15,7 @@ }, "child": { "type": "view", - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } } ] diff --git a/snapshot_tests/transition/change_rescaler_absolute_start.scene.json b/snapshot_tests/transition/change_rescaler_absolute_start.scene.json index b473b0b5f..70a32c242 100644 --- a/snapshot_tests/transition/change_rescaler_absolute_start.scene.json +++ b/snapshot_tests/transition/change_rescaler_absolute_start.scene.json @@ -12,7 +12,7 @@ "right": 20, "child": { "type": "view", - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } } ] diff --git a/snapshot_tests/transition/change_view_absolute_cubic_bezier_end.scene.json b/snapshot_tests/transition/change_view_absolute_cubic_bezier_end.scene.json index b5c5f31b6..164b9f7da 100644 --- a/snapshot_tests/transition/change_view_absolute_cubic_bezier_end.scene.json +++ b/snapshot_tests/transition/change_view_absolute_cubic_bezier_end.scene.json @@ -22,7 +22,7 @@ ] } }, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_end.scene.json b/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_end.scene.json index 49b4a065e..28a6d3206 100644 --- a/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_end.scene.json +++ b/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_end.scene.json @@ -22,7 +22,7 @@ ] } }, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_start.scene.json b/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_start.scene.json index 62c47a4aa..f7875122a 100644 --- a/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_start.scene.json +++ b/snapshot_tests/transition/change_view_absolute_cubic_bezier_linear_like_start.scene.json @@ -10,7 +10,7 @@ "height": 200, "top": 0, "right": 0, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_absolute_cubic_bezier_start.scene.json b/snapshot_tests/transition/change_view_absolute_cubic_bezier_start.scene.json index 62c47a4aa..f7875122a 100644 --- a/snapshot_tests/transition/change_view_absolute_cubic_bezier_start.scene.json +++ b/snapshot_tests/transition/change_view_absolute_cubic_bezier_start.scene.json @@ -10,7 +10,7 @@ "height": 200, "top": 0, "right": 0, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_absolute_end.scene.json b/snapshot_tests/transition/change_view_absolute_end.scene.json index 380ead940..49d89dbb0 100644 --- a/snapshot_tests/transition/change_view_absolute_end.scene.json +++ b/snapshot_tests/transition/change_view_absolute_end.scene.json @@ -13,7 +13,7 @@ "transition": { "duration_ms": 10000 }, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_absolute_start.scene.json b/snapshot_tests/transition/change_view_absolute_start.scene.json index e74db2990..c4ff54a59 100644 --- a/snapshot_tests/transition/change_view_absolute_start.scene.json +++ b/snapshot_tests/transition/change_view_absolute_start.scene.json @@ -10,7 +10,7 @@ "height": 200, "top": 20, "right": 20, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/transition/change_view_height_end.scene.json b/snapshot_tests/transition/change_view_height_end.scene.json index 3ef7782cb..903ab78f0 100644 --- a/snapshot_tests/transition/change_view_height_end.scene.json +++ b/snapshot_tests/transition/change_view_height_end.scene.json @@ -6,7 +6,7 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "id": "resize_1", @@ -16,11 +16,11 @@ "transition": { "duration_ms": 10000 }, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/transition/change_view_height_start.scene.json b/snapshot_tests/transition/change_view_height_start.scene.json index 41aeeebad..5824f8d03 100644 --- a/snapshot_tests/transition/change_view_height_start.scene.json +++ b/snapshot_tests/transition/change_view_height_start.scene.json @@ -6,18 +6,18 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "id": "resize_1", "type": "view", "width": 250, "height": 100, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/transition/change_view_width_after_end.scene.json b/snapshot_tests/transition/change_view_width_after_end.scene.json index 560efc043..4050f5c3b 100644 --- a/snapshot_tests/transition/change_view_width_after_end.scene.json +++ b/snapshot_tests/transition/change_view_width_after_end.scene.json @@ -6,17 +6,17 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "id": "resize_1", "type": "view", "width": 250, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/transition/change_view_width_after_end_without_id.scene.json b/snapshot_tests/transition/change_view_width_after_end_without_id.scene.json index c1b4d0149..534221a8d 100644 --- a/snapshot_tests/transition/change_view_width_after_end_without_id.scene.json +++ b/snapshot_tests/transition/change_view_width_after_end_without_id.scene.json @@ -6,16 +6,16 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 250, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/transition/change_view_width_end.scene.json b/snapshot_tests/transition/change_view_width_end.scene.json index 27a02a2a0..c2f9063e9 100644 --- a/snapshot_tests/transition/change_view_width_end.scene.json +++ b/snapshot_tests/transition/change_view_width_end.scene.json @@ -6,7 +6,7 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "id": "resize_1", @@ -15,11 +15,11 @@ "transition": { "duration_ms": 10000 }, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/transition/change_view_width_start.scene.json b/snapshot_tests/transition/change_view_width_start.scene.json index 2be28d02f..4c4b54277 100644 --- a/snapshot_tests/transition/change_view_width_start.scene.json +++ b/snapshot_tests/transition/change_view_width_start.scene.json @@ -6,17 +6,17 @@ { "type": "view", "width": 50, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "id": "resize_1", "type": "view", "width": 50, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/border_radius.scene.json b/snapshot_tests/view/border_radius.scene.json index c1ee5d386..7dd1cf9cd 100644 --- a/snapshot_tests/view/border_radius.scene.json +++ b/snapshot_tests/view/border_radius.scene.json @@ -2,11 +2,11 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, diff --git a/snapshot_tests/view/border_radius_border_box_shadow.scene.json b/snapshot_tests/view/border_radius_border_box_shadow.scene.json index 93ba6bcd2..316d8561e 100644 --- a/snapshot_tests/view/border_radius_border_box_shadow.scene.json +++ b/snapshot_tests/view/border_radius_border_box_shadow.scene.json @@ -2,24 +2,24 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/border_radius_border_box_shadow_rescaled.scene.json b/snapshot_tests/view/border_radius_border_box_shadow_rescaled.scene.json index 4849370b2..14c4af72e 100644 --- a/snapshot_tests/view/border_radius_border_box_shadow_rescaled.scene.json +++ b/snapshot_tests/view/border_radius_border_box_shadow_rescaled.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "rescaler", @@ -12,18 +12,18 @@ "vertical_align": "center", "child": { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 200, "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 20, "offset_y": 20, "blur_radius": 5, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/border_radius_border_box_shadow_rescaled_and_hidden_by_parent.scene.json b/snapshot_tests/view/border_radius_border_box_shadow_rescaled_and_hidden_by_parent.scene.json index 45c66220f..a55f753a9 100644 --- a/snapshot_tests/view/border_radius_border_box_shadow_rescaled_and_hidden_by_parent.scene.json +++ b/snapshot_tests/view/border_radius_border_box_shadow_rescaled_and_hidden_by_parent.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", @@ -17,18 +17,18 @@ "vertical_align": "center", "child": { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "width": 200, "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 20, "offset_y": 20, "blur_radius": 5, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/border_radius_box_shadow.scene.json b/snapshot_tests/view/border_radius_box_shadow.scene.json index 7529d7fb7..ff46028b8 100644 --- a/snapshot_tests/view/border_radius_box_shadow.scene.json +++ b/snapshot_tests/view/border_radius_box_shadow.scene.json @@ -2,11 +2,11 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, @@ -17,7 +17,7 @@ "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/border_radius_box_shadow_overflow_fit.scene.json b/snapshot_tests/view/border_radius_box_shadow_overflow_fit.scene.json index 8cc107791..e81c14a35 100644 --- a/snapshot_tests/view/border_radius_box_shadow_overflow_fit.scene.json +++ b/snapshot_tests/view/border_radius_box_shadow_overflow_fit.scene.json @@ -2,11 +2,11 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "overflow": "fit", "top": 50, "left": 50, @@ -14,13 +14,13 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "children": [ diff --git a/snapshot_tests/view/border_radius_box_shadow_overflow_hidden.scene.json b/snapshot_tests/view/border_radius_box_shadow_overflow_hidden.scene.json index cc401d7a5..0806df72f 100644 --- a/snapshot_tests/view/border_radius_box_shadow_overflow_hidden.scene.json +++ b/snapshot_tests/view/border_radius_box_shadow_overflow_hidden.scene.json @@ -2,24 +2,24 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "children": [ diff --git a/snapshot_tests/view/border_radius_box_shadow_rescaler_input_stream.scene.json b/snapshot_tests/view/border_radius_box_shadow_rescaler_input_stream.scene.json index 3cb05a51a..89805ecce 100644 --- a/snapshot_tests/view/border_radius_box_shadow_rescaler_input_stream.scene.json +++ b/snapshot_tests/view/border_radius_box_shadow_rescaler_input_stream.scene.json @@ -2,24 +2,24 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ], "children": [ diff --git a/snapshot_tests/view/border_width.scene.json b/snapshot_tests/view/border_width.scene.json index 91410189a..e74ed4342 100644 --- a/snapshot_tests/view/border_width.scene.json +++ b/snapshot_tests/view/border_width.scene.json @@ -2,17 +2,17 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, "height": 200, "border_width": 20, - "border_color_rgba": "#FFFFFFFF" + "border_color": "#FFFFFFFF" } ] } diff --git a/snapshot_tests/view/box_shadow.scene.json b/snapshot_tests/view/box_shadow.scene.json index 00df6994b..ec0fd59cb 100644 --- a/snapshot_tests/view/box_shadow.scene.json +++ b/snapshot_tests/view/box_shadow.scene.json @@ -2,11 +2,11 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "top": 50, "left": 50, "width": 400, @@ -16,7 +16,7 @@ "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/box_shadow_sibling.scene.json b/snapshot_tests/view/box_shadow_sibling.scene.json index 16e214f56..e12eb3f6c 100644 --- a/snapshot_tests/view/box_shadow_sibling.scene.json +++ b/snapshot_tests/view/box_shadow_sibling.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "overflow": "visible", "top": 100, "left": 100, @@ -14,33 +14,33 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "box_shadow": [ { "offset_x": 0, "offset_y": 60, "blur_radius": 30, - "color_rgba": "#FF0000FF" + "color": "#FF0000FF" }, { "offset_x": -60, "offset_y": -30, "blur_radius": 30, - "color_rgba": "#0000FFFF" + "color": "#0000FFFF" } ] }, { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#0000FFFF" + "color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/constant_width_and_height_views_row.scene.json b/snapshot_tests/view/constant_width_and_height_views_row.scene.json index bb70ef37c..ee16c031b 100644 --- a/snapshot_tests/view/constant_width_and_height_views_row.scene.json +++ b/snapshot_tests/view/constant_width_and_height_views_row.scene.json @@ -7,19 +7,19 @@ "type": "view", "width": 200, "height": 300, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 200, "height": 200, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", "width": 200, "height": 300, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/constant_width_views_row.scene.json b/snapshot_tests/view/constant_width_views_row.scene.json index 7f8f534a8..082017692 100644 --- a/snapshot_tests/view/constant_width_views_row.scene.json +++ b/snapshot_tests/view/constant_width_views_row.scene.json @@ -6,17 +6,17 @@ { "type": "view", "width": 200, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 200, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", "width": 200, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/constant_width_views_row_with_overflow_fit.scene.json b/snapshot_tests/view/constant_width_views_row_with_overflow_fit.scene.json index 1a63a890b..c9acf4046 100644 --- a/snapshot_tests/view/constant_width_views_row_with_overflow_fit.scene.json +++ b/snapshot_tests/view/constant_width_views_row_with_overflow_fit.scene.json @@ -5,37 +5,37 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 300, - "background_color_rgba": "#00FF00FF", + "background_color": "#00FF00FF", "overflow": "fit", "children": [ { "type": "view", "width": 200, "height": 200, - "background_color_rgba": "#00FFFFFF" + "background_color": "#00FFFFFF" }, { "type": "view", "width": 200, "height": 200, - "background_color_rgba": "#FFFF00FF" + "background_color": "#FFFF00FF" }, { "type": "view", "width": 200, "height": 200, - "background_color_rgba": "#FF00FFFF" + "background_color": "#FF00FFFF" }, { "type": "view", "width": 300, "height": 50, - "background_color_rgba": "#FFFFFFFF", + "background_color": "#FFFFFFFF", "top": 50, "left": 50 } @@ -43,7 +43,7 @@ }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/constant_width_views_row_with_overflow_hidden.scene.json b/snapshot_tests/view/constant_width_views_row_with_overflow_hidden.scene.json index eb20db89b..9fd397b2b 100644 --- a/snapshot_tests/view/constant_width_views_row_with_overflow_hidden.scene.json +++ b/snapshot_tests/view/constant_width_views_row_with_overflow_hidden.scene.json @@ -6,18 +6,18 @@ { "type": "view", "width": 300, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 300, - "background_color_rgba": "#00FF00FF", + "background_color": "#00FF00FF", "children": [ { "type": "view", "width": 500, "height": 100, - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "top": 100, "left": -100 } @@ -26,7 +26,7 @@ { "type": "view", "width": 300, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/constant_width_views_row_with_overflow_visible.scene.json b/snapshot_tests/view/constant_width_views_row_with_overflow_visible.scene.json index c2611542f..8636e0c4c 100644 --- a/snapshot_tests/view/constant_width_views_row_with_overflow_visible.scene.json +++ b/snapshot_tests/view/constant_width_views_row_with_overflow_visible.scene.json @@ -6,19 +6,19 @@ { "type": "view", "width": 300, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 300, - "background_color_rgba": "#00FF00FF", + "background_color": "#00FF00FF", "overflow": "visible", "children": [ { "type": "view", "width": 500, "height": 100, - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "top": 100, "left": -100 } @@ -27,7 +27,7 @@ { "type": "view", "width": 300, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/dynamic_and_constant_width_views_row.scene.json b/snapshot_tests/view/dynamic_and_constant_width_views_row.scene.json index b3bb81932..6ade4757f 100644 --- a/snapshot_tests/view/dynamic_and_constant_width_views_row.scene.json +++ b/snapshot_tests/view/dynamic_and_constant_width_views_row.scene.json @@ -5,17 +5,17 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 100, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", "width": 100, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/dynamic_and_constant_width_views_row_with_overflow.scene.json b/snapshot_tests/view/dynamic_and_constant_width_views_row_with_overflow.scene.json index 0aa4a4b1e..0ea3dd9fa 100644 --- a/snapshot_tests/view/dynamic_and_constant_width_views_row_with_overflow.scene.json +++ b/snapshot_tests/view/dynamic_and_constant_width_views_row_with_overflow.scene.json @@ -5,17 +5,17 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 400, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", "width": 400, - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/dynamic_width_views_row.scene.json b/snapshot_tests/view/dynamic_width_views_row.scene.json index a05fdcd86..08983edb0 100644 --- a/snapshot_tests/view/dynamic_width_views_row.scene.json +++ b/snapshot_tests/view/dynamic_width_views_row.scene.json @@ -5,15 +5,15 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/nested_border_width_radius.scene.json b/snapshot_tests/view/nested_border_width_radius.scene.json index d13427f84..3b1423f8d 100644 --- a/snapshot_tests/view/nested_border_width_radius.scene.json +++ b/snapshot_tests/view/nested_border_width_radius.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", @@ -12,19 +12,19 @@ "height": 200, "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FF0000FF", + "border_color": "#FF0000FF", "children": [ { "type": "view", "border_radius": 50, "border_width": 20, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "children": [ { "type": "view", "border_radius": 50, "border_width": 20, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "children": [ { "type": "input_stream", diff --git a/snapshot_tests/view/nested_border_width_radius_aligned.scene.json b/snapshot_tests/view/nested_border_width_radius_aligned.scene.json index ddf6b7cc2..35457698f 100644 --- a/snapshot_tests/view/nested_border_width_radius_aligned.scene.json +++ b/snapshot_tests/view/nested_border_width_radius_aligned.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", @@ -12,19 +12,19 @@ "height": 200, "border_radius": 80, "border_width": 20, - "border_color_rgba": "#FF0000FF", + "border_color": "#FF0000FF", "children": [ { "type": "view", "border_radius": 60, "border_width": 20, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "children": [ { "type": "view", "border_radius": 40, "border_width": 20, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "children": [ { "type": "input_stream", diff --git a/snapshot_tests/view/nested_border_width_radius_multi_child.scene.json b/snapshot_tests/view/nested_border_width_radius_multi_child.scene.json index c8a740c7f..4f4c19d41 100644 --- a/snapshot_tests/view/nested_border_width_radius_multi_child.scene.json +++ b/snapshot_tests/view/nested_border_width_radius_multi_child.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FFFF00FF", + "background_color": "#FFFF00FF", "children": [ { "type": "view", @@ -12,19 +12,19 @@ "height": 200, "border_radius": 50, "border_width": 10, - "border_color_rgba": "#FF0000FF", + "border_color": "#FF0000FF", "children": [ { "type": "view", "border_radius": 40, "border_width": 10, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "children": [ { "type": "view", "border_radius": 30, "border_width": 10, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "children": [ { "type": "input_stream", @@ -38,13 +38,13 @@ "type": "view", "border_radius": 40, "border_width": 10, - "border_color_rgba": "#00FF00FF", + "border_color": "#00FF00FF", "children": [ { "type": "view", "border_radius": 30, "border_width": 10, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "children": [ { "type": "input_stream", @@ -56,7 +56,7 @@ "type": "view", "border_radius": 30, "border_width": 10, - "border_color_rgba": "#0000FFFF", + "border_color": "#0000FFFF", "children": [ { "type": "input_stream", diff --git a/snapshot_tests/view/overflow_hidden_with_input_stream_children.scene.json b/snapshot_tests/view/overflow_hidden_with_input_stream_children.scene.json index d0e035c80..98cc25aae 100644 --- a/snapshot_tests/view/overflow_hidden_with_input_stream_children.scene.json +++ b/snapshot_tests/view/overflow_hidden_with_input_stream_children.scene.json @@ -6,12 +6,12 @@ { "type": "view", "width": 100, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 300, - "background_color_rgba": "#00FF00FF", + "background_color": "#00FF00FF", "children": [ { "type": "input_stream", diff --git a/snapshot_tests/view/overflow_hidden_with_view_children.scene.json b/snapshot_tests/view/overflow_hidden_with_view_children.scene.json index e07ccc387..26295d7c3 100644 --- a/snapshot_tests/view/overflow_hidden_with_view_children.scene.json +++ b/snapshot_tests/view/overflow_hidden_with_view_children.scene.json @@ -6,30 +6,30 @@ { "type": "view", "width": 100, - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", "width": 300, - "background_color_rgba": "#00FF00FF", + "background_color": "#00FF00FF", "children": [ { "type": "view", "width": 180, "height": 200, - "background_color_rgba": "#FFFF00FF" + "background_color": "#FFFF00FF" }, { "type": "view", "width": 180, "height": 200, - "background_color_rgba": "#BBBB00FF" + "background_color": "#BBBB00FF" }, { "type": "view", "width": 180, "height": 200, - "background_color_rgba": "#888800FF" + "background_color": "#888800FF" } ] } diff --git a/snapshot_tests/view/root_border_radius_border_box_shadow.scene.json b/snapshot_tests/view/root_border_radius_border_box_shadow.scene.json index 3e424d513..10e1f1c61 100644 --- a/snapshot_tests/view/root_border_radius_border_box_shadow.scene.json +++ b/snapshot_tests/view/root_border_radius_border_box_shadow.scene.json @@ -2,16 +2,16 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "border_radius": 50, "border_width": 20, - "border_color_rgba": "#FFFFFFFF", + "border_color": "#FFFFFFFF", "box_shadow": [ { "offset_x": 60, "offset_y": 30, "blur_radius": 30, - "color_rgba": "#00FF00FF" + "color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/root_view_with_background_color.scene.json b/snapshot_tests/view/root_view_with_background_color.scene.json index 8d8e88532..7af395afa 100644 --- a/snapshot_tests/view/root_view_with_background_color.scene.json +++ b/snapshot_tests/view/root_view_with_background_color.scene.json @@ -2,7 +2,7 @@ "video": { "root": { "type": "view", - "background_color_rgba": "#FF0000FF", + "background_color": "#FF0000FF", "children": [ { "type": "view", @@ -10,7 +10,7 @@ "right": 50, "width": 400, "height": 200, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/snapshot_tests/view/view_with_absolute_positioning_partially_covered_by_sibling.scene.json b/snapshot_tests/view/view_with_absolute_positioning_partially_covered_by_sibling.scene.json index 1e18a72c9..61d82ec00 100644 --- a/snapshot_tests/view/view_with_absolute_positioning_partially_covered_by_sibling.scene.json +++ b/snapshot_tests/view/view_with_absolute_positioning_partially_covered_by_sibling.scene.json @@ -5,7 +5,7 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", @@ -13,11 +13,11 @@ "right": 50, "width": 400, "height": 200, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" } ] } diff --git a/snapshot_tests/view/view_with_absolute_positioning_render_over_siblings.scene.json b/snapshot_tests/view/view_with_absolute_positioning_render_over_siblings.scene.json index 448dbe466..6113cb5fe 100644 --- a/snapshot_tests/view/view_with_absolute_positioning_render_over_siblings.scene.json +++ b/snapshot_tests/view/view_with_absolute_positioning_render_over_siblings.scene.json @@ -5,11 +5,11 @@ "children": [ { "type": "view", - "background_color_rgba": "#FF0000FF" + "background_color": "#FF0000FF" }, { "type": "view", - "background_color_rgba": "#0000FFFF" + "background_color": "#0000FFFF" }, { "type": "view", @@ -17,7 +17,7 @@ "right": 50, "width": 400, "height": 200, - "background_color_rgba": "#00FF00FF" + "background_color": "#00FF00FF" } ] } diff --git a/ts/@live-compositor/core/src/api.ts b/ts/@live-compositor/core/src/api.ts index b2cd348f8..8e00347fe 100644 --- a/ts/@live-compositor/core/src/api.ts +++ b/ts/@live-compositor/core/src/api.ts @@ -1,7 +1,7 @@ import { Api } from 'live-compositor'; import type { CompositorManager } from './compositorManager.js'; import type { RegisterOutputRequest } from './api/output.js'; -import type { RegisterInputRequest } from './api/input.js'; +import { inputRefIntoRawId, type InputRef, type RegisterInputRequest } from './api/input.js'; export { Api }; @@ -11,6 +11,11 @@ export type ApiRequest = { body?: object; }; +export type RegisterInputResponse = { + video_duration_ms?: number; + audio_duration_ms?: number; +}; + export class ApiClient { private serverManager: CompositorManager; @@ -34,27 +39,36 @@ export class ApiClient { }); } - public async unregisterOutput(outptuId: string): Promise { + public async unregisterOutput( + outptuId: string, + body: { schedule_time_ms?: number } + ): Promise { return this.serverManager.sendRequest({ method: 'POST', route: `/api/output/${encodeURIComponent(outptuId)}/unregister`, - body: {}, + body, }); } - public async registerInput(inputId: string, request: RegisterInputRequest): Promise { + public async registerInput( + inputId: InputRef, + request: RegisterInputRequest + ): Promise { return this.serverManager.sendRequest({ method: 'POST', - route: `/api/input/${encodeURIComponent(inputId)}/register`, + route: `/api/input/${encodeURIComponent(inputRefIntoRawId(inputId))}/register`, body: request, }); } - public async unregisterInput(inputId: string): Promise { + public async unregisterInput( + inputId: InputRef, + body: { schedule_time_ms?: number } + ): Promise { return this.serverManager.sendRequest({ method: 'POST', - route: `/api/input/${encodeURIComponent(inputId)}/unregister`, - body: {}, + route: `/api/input/${encodeURIComponent(inputRefIntoRawId(inputId))}/unregister`, + body, }); } diff --git a/ts/@live-compositor/core/src/api/input.ts b/ts/@live-compositor/core/src/api/input.ts index 5c3048d3a..d8d05d882 100644 --- a/ts/@live-compositor/core/src/api/input.ts +++ b/ts/@live-compositor/core/src/api/input.ts @@ -1,8 +1,13 @@ import type { Api } from '../api.js'; import type { RegisterMp4Input, RegisterRtpInput, Inputs } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; export type RegisterInputRequest = Api.RegisterInput; +export type InputRef = _liveCompositorInternals.InputRef; +export const inputRefIntoRawId = _liveCompositorInternals.inputRefIntoRawId; +export const parseInputRef = _liveCompositorInternals.parseInputRef; + export type RegisterInput = | ({ type: 'rtp_stream' } & RegisterRtpInput) | ({ type: 'mp4' } & RegisterMp4Input); diff --git a/ts/@live-compositor/core/src/api/output.ts b/ts/@live-compositor/core/src/api/output.ts index 8708f0f04..ff7ddf9fc 100644 --- a/ts/@live-compositor/core/src/api/output.ts +++ b/ts/@live-compositor/core/src/api/output.ts @@ -4,7 +4,9 @@ import type { RegisterRtpOutput, RegisterMp4Output, RegisterCanvasOutput, + _liveCompositorInternals, } from 'live-compositor'; +import { inputRefIntoRawId } from './input.js'; export type RegisterOutputRequest = Api.RegisterOutput | RegisterCanvasOutputRequest; @@ -145,10 +147,12 @@ function intoMp4AudioEncoderOptions( }; } -export function intoAudioInputsConfiguration(audio: Outputs.AudioInputsConfiguration): Api.Audio { +export function intoAudioInputsConfiguration( + inputs: _liveCompositorInternals.AudioConfig +): Api.Audio { return { - inputs: audio.inputs.map(input => ({ - input_id: input.inputId, + inputs: inputs.map(input => ({ + input_id: inputRefIntoRawId(input.inputRef), volume: input.volume, })), }; diff --git a/ts/@live-compositor/core/src/compositorManager.ts b/ts/@live-compositor/core/src/compositorManager.ts index ebdcdca73..e45aa5a06 100644 --- a/ts/@live-compositor/core/src/compositorManager.ts +++ b/ts/@live-compositor/core/src/compositorManager.ts @@ -1,7 +1,14 @@ import type { ApiRequest } from './api.js'; +export interface SetupInstanceOptions { + /** + * sets LIVE_COMPOSITOR_AHEAD_OF_TIME_PROCESSING_ENABLE environment variable. + */ + aheadOfTimeProcessing: boolean; +} + export interface CompositorManager { - setupInstance(): Promise; + setupInstance(opts: SetupInstanceOptions): Promise; sendRequest(request: ApiRequest): Promise; registerEventListener(cb: (event: unknown) => void): void; } diff --git a/ts/@live-compositor/core/src/event.ts b/ts/@live-compositor/core/src/event.ts index f1d6af766..42bf97ea0 100644 --- a/ts/@live-compositor/core/src/event.ts +++ b/ts/@live-compositor/core/src/event.ts @@ -1,46 +1,10 @@ -import type { _liveCompositorInternals, CompositorEvent } from 'live-compositor'; -import { CompositorEventType } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; +import { parseInputRef } from './api/input.js'; -type InstanceContextStore = _liveCompositorInternals.InstanceContextStore; +export type CompositorEvent = _liveCompositorInternals.CompositorEvent; +export const CompositorEventType = _liveCompositorInternals.CompositorEventType; -export function onCompositorEvent(store: InstanceContextStore, rawEvent: unknown) { - const event = parseEvent(rawEvent); - if (!event) { - return; - } else if (event.type === CompositorEventType.VIDEO_INPUT_DELIVERED) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, videoState: 'ready' }, - }); - } else if (event.type === CompositorEventType.VIDEO_INPUT_PLAYING) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, videoState: 'playing' }, - }); - } else if (event.type === CompositorEventType.VIDEO_INPUT_EOS) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, videoState: 'finished' }, - }); - } else if (event.type === CompositorEventType.AUDIO_INPUT_DELIVERED) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, audioState: 'ready' }, - }); - } else if (event.type === CompositorEventType.AUDIO_INPUT_PLAYING) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, audioState: 'playing' }, - }); - } else if (event.type === CompositorEventType.AUDIO_INPUT_EOS) { - store.dispatchUpdate({ - type: 'update_input', - input: { inputId: event.inputId, audioState: 'finished' }, - }); - } -} - -function parseEvent(event: any): CompositorEvent | null { +export function parseEvent(event: any): CompositorEvent | null { if (!event.type) { console.error(`Malformed event: ${event}`); return null; @@ -54,9 +18,9 @@ function parseEvent(event: any): CompositorEvent | null { CompositorEventType.AUDIO_INPUT_EOS, ].includes(event.type) ) { - return { type: event.type, inputId: event.input_id }; + return { type: event.type, inputRef: parseInputRef(event.input_id) }; } else if (CompositorEventType.OUTPUT_DONE === event.type) { - return { type: event.type, outputId: event.outputId }; + return { type: event.type, outputId: event.output_id }; } else { console.error(`Unknown event type: ${event.type}`); return null; diff --git a/ts/@live-compositor/core/src/index.ts b/ts/@live-compositor/core/src/index.ts index fb0b860af..6010d8052 100644 --- a/ts/@live-compositor/core/src/index.ts +++ b/ts/@live-compositor/core/src/index.ts @@ -1,5 +1,6 @@ export { ApiClient, ApiRequest } from './api.js'; -export { LiveCompositor } from './compositor.js'; -export { CompositorManager } from './compositorManager.js'; +export { LiveCompositor } from './live/compositor.js'; +export { OfflineCompositor } from './offline/compositor.js'; +export { CompositorManager, SetupInstanceOptions } from './compositorManager.js'; export { RegisterInputRequest, RegisterInput } from './api/input.js'; export { RegisterOutputRequest, RegisterOutput } from './api/output.js'; diff --git a/ts/@live-compositor/core/src/compositor.ts b/ts/@live-compositor/core/src/live/compositor.ts similarity index 52% rename from ts/@live-compositor/core/src/compositor.ts rename to ts/@live-compositor/core/src/live/compositor.ts index e06c16f10..6667dc7e7 100644 --- a/ts/@live-compositor/core/src/compositor.ts +++ b/ts/@live-compositor/core/src/live/compositor.ts @@ -1,34 +1,41 @@ import type { Renderers } from 'live-compositor'; import { _liveCompositorInternals } from 'live-compositor'; -import { ApiClient } from './api.js'; +import { ApiClient } from '../api.js'; import Output from './output.js'; -import type { CompositorManager } from './compositorManager.js'; -import type { RegisterOutput } from './api/output.js'; -import { intoRegisterOutput } from './api/output.js'; -import type { RegisterInput } from './api/input.js'; -import { intoRegisterInput } from './api/input.js'; -import { onCompositorEvent } from './event.js'; -import { intoRegisterImage, intoRegisterWebRenderer } from './api/renderer.js'; +import type { CompositorManager } from '../compositorManager.js'; +import type { RegisterOutput } from '../api/output.js'; +import { intoRegisterOutput } from '../api/output.js'; +import type { RegisterInput } from '../api/input.js'; +import { intoRegisterInput } from '../api/input.js'; +import { parseEvent } from '../event.js'; +import { intoRegisterImage, intoRegisterWebRenderer } from '../api/renderer.js'; +import { handleEvent } from './event.js'; +import type { ReactElement } from 'react'; export class LiveCompositor { private manager: CompositorManager; private api: ApiClient; - private store: _liveCompositorInternals.InstanceContextStore; + private store: _liveCompositorInternals.LiveInputStreamStore; private outputs: Record = {}; + private startTime?: number; public constructor(manager: CompositorManager) { this.manager = manager; this.api = new ApiClient(this.manager); - this.store = new _liveCompositorInternals.InstanceContextStore(); + this.store = new _liveCompositorInternals.LiveInputStreamStore(); } public async init(): Promise { - this.manager.registerEventListener((event: unknown) => onCompositorEvent(this.store, event)); - await this.manager.setupInstance(); + this.manager.registerEventListener((event: unknown) => this.handleEvent(event)); + await this.manager.setupInstance({ aheadOfTimeProcessing: false }); } - public async registerOutput(outputId: string, request: RegisterOutput): Promise { - const output = new Output(outputId, request, this.api, this.store); + public async registerOutput( + outputId: string, + root: ReactElement, + request: RegisterOutput + ): Promise { + const output = new Output(outputId, root, request, this.api, this.store, this.startTime); const apiRequest = intoRegisterOutput(request, output.scene()); const result = await this.api.registerOutput(outputId, apiRequest); @@ -40,20 +47,30 @@ export class LiveCompositor { public async unregisterOutput(outputId: string): Promise { this.outputs[outputId].close(); delete this.outputs[outputId]; - return this.api.unregisterOutput(outputId); + // TODO: wait for event + return this.api.unregisterOutput(outputId, {}); } public async registerInput(inputId: string, request: RegisterInput): Promise { return this.store.runBlocking(async updateStore => { - const result = await this.api.registerInput(inputId, intoRegisterInput(request)); - updateStore({ type: 'add_input', input: { inputId } }); + const inputRef = { type: 'global', id: inputId } as const; + const result = await this.api.registerInput(inputRef, intoRegisterInput(request)); + updateStore({ + type: 'add_input', + input: { + inputId, + videoDurationMs: result.video_duration_ms, + audioDurationMs: result.audio_duration_ms, + }, + }); return result; }); } public async unregisterInput(inputId: string): Promise { return this.store.runBlocking(async updateStore => { - const result = this.api.unregisterInput(inputId); + const inputRef = { type: 'global', id: inputId } as const; + const result = this.api.unregisterInput(inputRef, {}); updateStore({ type: 'remove_input', inputId }); return result; }); @@ -90,6 +107,19 @@ export class LiveCompositor { } public async start(): Promise { - return this.api.start(); + const startTime = Date.now(); + await this.api.start(); + Object.values(this.outputs).forEach(output => { + output.initClock(startTime); + }); + this.startTime = startTime; + } + + private handleEvent(rawEvent: unknown) { + const event = parseEvent(rawEvent); + if (!event) { + return; + } + handleEvent(this.store, this.outputs, event); } } diff --git a/ts/@live-compositor/core/src/live/event.ts b/ts/@live-compositor/core/src/live/event.ts new file mode 100644 index 000000000..1a4d94bf0 --- /dev/null +++ b/ts/@live-compositor/core/src/live/event.ts @@ -0,0 +1,86 @@ +import type { _liveCompositorInternals } from 'live-compositor'; +import type { CompositorEvent } from '../event.js'; +import { CompositorEventType } from '../event.js'; +import type Output from './output.js'; + +type LiveInputStreamStore = _liveCompositorInternals.LiveInputStreamStore; + +export function handleEvent( + store: LiveInputStreamStore, + outputs: Record, + event: CompositorEvent +) { + if (event.type === CompositorEventType.VIDEO_INPUT_DELIVERED) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'ready' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'ready' }, + }); + } + } else if (event.type === CompositorEventType.VIDEO_INPUT_PLAYING) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'playing' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'playing' }, + }); + } + } else if (event.type === CompositorEventType.VIDEO_INPUT_EOS) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'finished' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, videoState: 'finished' }, + }); + } + } else if (event.type === CompositorEventType.AUDIO_INPUT_DELIVERED) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'ready' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'ready' }, + }); + } + } else if (event.type === CompositorEventType.AUDIO_INPUT_PLAYING) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'playing' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'playing' }, + }); + } + } else if (event.type === CompositorEventType.AUDIO_INPUT_EOS) { + if (event.inputRef.type === 'global') { + store.dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'finished' }, + }); + } else if (event.inputRef.type === 'output-local') { + outputs[event.inputRef.outputId]?.inputStreamStore().dispatchUpdate({ + type: 'update_input', + input: { inputId: event.inputRef.id, audioState: 'finished' }, + }); + } + } +} diff --git a/ts/@live-compositor/core/src/live/output.ts b/ts/@live-compositor/core/src/live/output.ts new file mode 100644 index 000000000..432f3c16d --- /dev/null +++ b/ts/@live-compositor/core/src/live/output.ts @@ -0,0 +1,173 @@ +import type { RegisterMp4Input } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; +import type { ReactElement } from 'react'; +import { createElement } from 'react'; +import type { ApiClient, Api } from '../api.js'; +import Renderer from '../renderer.js'; +import type { RegisterOutput } from '../api/output.js'; +import { intoAudioInputsConfiguration } from '../api/output.js'; +import { throttle } from '../utils.js'; +import { OutputRootComponent, OutputShutdownStateStore } from '../rootComponent.js'; + +type AudioContext = _liveCompositorInternals.AudioContext; +type LiveTimeContext = _liveCompositorInternals.LiveTimeContext; +type LiveInputStreamStore = _liveCompositorInternals.LiveInputStreamStore; +type CompositorOutputContext = _liveCompositorInternals.CompositorOutputContext; + +class Output { + api: ApiClient; + outputId: string; + audioContext: AudioContext; + timeContext: LiveTimeContext; + internalInputStreamStore: LiveInputStreamStore; + outputShutdownStateStore: OutputShutdownStateStore; + + shouldUpdateWhenReady: boolean = false; + throttledUpdate: () => void; + + supportsAudio: boolean; + supportsVideo: boolean; + + renderer: Renderer; + + constructor( + outputId: string, + root: ReactElement, + registerRequest: RegisterOutput, + api: ApiClient, + store: LiveInputStreamStore, + startTimestamp: number | undefined + ) { + this.api = api; + this.outputId = outputId; + this.outputShutdownStateStore = new OutputShutdownStateStore(); + this.shouldUpdateWhenReady = false; + this.throttledUpdate = () => { + this.shouldUpdateWhenReady = true; + }; + + this.supportsAudio = 'audio' in registerRequest && !!registerRequest.audio; + this.supportsVideo = 'video' in registerRequest && !!registerRequest.video; + + const onUpdate = () => this.throttledUpdate(); + this.audioContext = new _liveCompositorInternals.AudioContext(onUpdate); + this.timeContext = new _liveCompositorInternals.LiveTimeContext(); + this.internalInputStreamStore = new _liveCompositorInternals.LiveInputStreamStore(); + if (startTimestamp !== undefined) { + this.timeContext.initClock(startTimestamp); + } + + const rootElement = createElement(OutputRootComponent, { + outputContext: new OutputContext(this, this.outputId, store), + outputRoot: root, + outputShutdownStateStore: this.outputShutdownStateStore, + childrenLifetimeContext: new _liveCompositorInternals.ChildrenLifetimeContext(() => {}), + }); + + this.renderer = new Renderer({ + rootElement, + onUpdate, + idPrefix: `${outputId}-`, + }); + } + + public scene(): { video?: Api.Video; audio?: Api.Audio } { + const audio = this.supportsAudio + ? intoAudioInputsConfiguration(this.audioContext.getAudioConfig()) + : undefined; + const video = this.supportsVideo ? { root: this.renderer.scene() } : undefined; + return { + audio, + video, + }; + } + + public close(): void { + this.throttledUpdate = () => {}; + // close will switch a scene to just a , so we need replace `throttledUpdate` + // callback before it is called + this.outputShutdownStateStore.close(); + } + + public async ready() { + this.throttledUpdate = throttle(async () => { + await this.api.updateScene(this.outputId, this.scene()); + }, 30); + if (this.shouldUpdateWhenReady) { + this.throttledUpdate(); + } + } + + public initClock(timestamp: number) { + this.timeContext.initClock(timestamp); + } + + public inputStreamStore(): LiveInputStreamStore { + return this.internalInputStreamStore; + } +} + +class OutputContext implements CompositorOutputContext { + public readonly globalInputStreamStore: _liveCompositorInternals.InputStreamStore; + public readonly internalInputStreamStore: _liveCompositorInternals.InputStreamStore; + public readonly audioContext: _liveCompositorInternals.AudioContext; + public readonly timeContext: _liveCompositorInternals.TimeContext; + public readonly outputId: string; + private output: Output; + + constructor( + output: Output, + outputId: string, + store: _liveCompositorInternals.InputStreamStore + ) { + this.output = output; + this.globalInputStreamStore = store; + this.internalInputStreamStore = output.internalInputStreamStore; + this.audioContext = output.audioContext; + this.timeContext = output.timeContext; + this.outputId = outputId; + } + + public async registerMp4Input( + inputId: number, + registerRequest: RegisterMp4Input + ): Promise<{ videoDurationMs?: number; audioDurationMs?: number }> { + return await this.output.internalInputStreamStore.runBlocking(async updateStore => { + const inputRef = { + type: 'output-local', + outputId: this.outputId, + id: inputId, + } as const; + const { video_duration_ms: videoDurationMs, audio_duration_ms: audioDurationMs } = + await this.output.api.registerInput(inputRef, { + type: 'mp4', + ...registerRequest, + }); + updateStore({ + type: 'add_input', + input: { + inputId: inputId, + offsetMs: registerRequest.offsetMs, + audioDurationMs, + videoDurationMs, + }, + }); + return { + audioDurationMs, + videoDurationMs, + }; + }); + } + public async unregisterMp4Input(inputId: number): Promise { + await this.output.api.unregisterInput( + { + type: 'output-local', + outputId: this.outputId, + id: inputId, + }, + {} + ); + } +} + +export default Output; diff --git a/ts/@live-compositor/core/src/offline/compositor.ts b/ts/@live-compositor/core/src/offline/compositor.ts new file mode 100644 index 000000000..c0c548266 --- /dev/null +++ b/ts/@live-compositor/core/src/offline/compositor.ts @@ -0,0 +1,135 @@ +import type { Renderers } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; +import { ApiClient } from '../api.js'; +import type { CompositorManager } from '../compositorManager.js'; +import type { RegisterOutput } from '../api/output.js'; +import { intoRegisterOutput } from '../api/output.js'; +import type { RegisterInput } from '../api/input.js'; +import { intoRegisterInput } from '../api/input.js'; +import { intoRegisterImage, intoRegisterWebRenderer } from '../api/renderer.js'; +import OfflineOutput from './output.js'; +import { CompositorEventType, parseEvent } from '../event.js'; +import type { ReactElement } from 'react'; + +/** + * Offline rendering only supports one output, so we can just pick any value to use + * as an output ID. + */ +export const OFFLINE_OUTPUT_ID = 'offline_output'; + +export class OfflineCompositor { + private manager: CompositorManager; + private api: ApiClient; + private store: _liveCompositorInternals.OfflineInputStreamStore; + private renderStarted: boolean = false; + /** + * Start and end timestamp of an inputs (if known). + */ + private inputTimestamps: number[] = []; + + public constructor(manager: CompositorManager) { + this.manager = manager; + this.api = new ApiClient(this.manager); + this.store = new _liveCompositorInternals.OfflineInputStreamStore(); + } + + public async init(): Promise { + this.checkNotStarted(); + await this.manager.setupInstance({ aheadOfTimeProcessing: true }); + } + + public async render(root: ReactElement, request: RegisterOutput, durationMs?: number) { + this.checkNotStarted(); + this.renderStarted = true; + + const output = new OfflineOutput(root, request, this.api, this.store, durationMs); + for (const inputTimestamp of this.inputTimestamps) { + output.timeContext.addTimestamp({ timestamp: inputTimestamp }); + } + const apiRequest = intoRegisterOutput(request, output.scene()); + await this.api.registerOutput(OFFLINE_OUTPUT_ID, apiRequest); + await output.scheduleAllUpdates(); + // at this point all scene update requests should already be delivered + output.outputShutdownStateStore.close(); + + if (durationMs) { + await this.api.unregisterOutput(OFFLINE_OUTPUT_ID, { schedule_time_ms: durationMs }); + } + + const renderPromise = new Promise((res, _rej) => { + this.manager.registerEventListener(rawEvent => { + const event = parseEvent(rawEvent); + if ( + event && + event.type === CompositorEventType.OUTPUT_DONE && + event.outputId === OFFLINE_OUTPUT_ID + ) { + res(); + } + }); + }); + + await this.api.start(); + + await renderPromise; + } + + public async registerInput(inputId: string, request: RegisterInput): Promise { + this.checkNotStarted(); + const inputRef = { type: 'global', id: inputId } as const; + const result = await this.api.registerInput(inputRef, intoRegisterInput(request)); + + if (request.type === 'mp4' && request.loop) { + this.store.addInput({ + inputId, + offsetMs: request.offsetMs ?? 0, + videoDurationMs: Infinity, + audioDurationMs: Infinity, + }); + } else { + this.store.addInput({ + inputId, + offsetMs: request.offsetMs ?? 0, + videoDurationMs: result.video_duration_ms, + audioDurationMs: result.audio_duration_ms, + }); + if (request.offsetMs) { + this.inputTimestamps.push(request.offsetMs); + } + if (result.video_duration_ms) { + this.inputTimestamps.push((request.offsetMs ?? 0) + result.video_duration_ms); + } + if (result.audio_duration_ms) { + this.inputTimestamps.push((request.offsetMs ?? 0) + result.audio_duration_ms); + } + } + return result; + } + + public async registerShader( + shaderId: string, + request: Renderers.RegisterShader + ): Promise { + this.checkNotStarted(); + return this.api.registerShader(shaderId, request); + } + + public async registerImage(imageId: string, request: Renderers.RegisterImage): Promise { + this.checkNotStarted(); + return this.api.registerImage(imageId, intoRegisterImage(request)); + } + + public async registerWebRenderer( + instanceId: string, + request: Renderers.RegisterWebRenderer + ): Promise { + this.checkNotStarted(); + return this.api.registerWebRenderer(instanceId, intoRegisterWebRenderer(request)); + } + + private checkNotStarted() { + if (this.renderStarted) { + throw new Error('Render was already started.'); + } + } +} diff --git a/ts/@live-compositor/core/src/offline/output.ts b/ts/@live-compositor/core/src/offline/output.ts new file mode 100644 index 000000000..46b30dde4 --- /dev/null +++ b/ts/@live-compositor/core/src/offline/output.ts @@ -0,0 +1,242 @@ +import type { RegisterMp4Input } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; +import type { ReactElement } from 'react'; +import { createElement } from 'react'; +import type { ApiClient, Api } from '../api.js'; +import Renderer from '../renderer.js'; +import type { RegisterOutput } from '../api/output.js'; +import { intoAudioInputsConfiguration } from '../api/output.js'; +import { sleep } from '../utils.js'; +import { OFFLINE_OUTPUT_ID } from './compositor.js'; +import { OutputRootComponent, OutputShutdownStateStore } from '../rootComponent.js'; + +type AudioContext = _liveCompositorInternals.AudioContext; +type OfflineTimeContext = _liveCompositorInternals.OfflineTimeContext; +type OfflineInputStreamStore = _liveCompositorInternals.OfflineInputStreamStore; +type CompositorOutputContext = _liveCompositorInternals.CompositorOutputContext; +type ChildrenLifetimeContext = _liveCompositorInternals.ChildrenLifetimeContext; + +class OfflineOutput { + api: ApiClient; + outputId: string; + audioContext: AudioContext; + timeContext: OfflineTimeContext; + childrenLifetimeContext: ChildrenLifetimeContext; + internalInputStreamStore: OfflineInputStreamStore; + outputShutdownStateStore: OutputShutdownStateStore; + durationMs?: number; + updateTracker?: UpdateTracker; + + supportsAudio: boolean; + supportsVideo: boolean; + + renderer: Renderer; + + constructor( + root: ReactElement, + registerRequest: RegisterOutput, + api: ApiClient, + store: OfflineInputStreamStore, + durationMs?: number + ) { + this.api = api; + this.outputId = OFFLINE_OUTPUT_ID; + this.outputShutdownStateStore = new OutputShutdownStateStore(); + this.durationMs = durationMs; + + this.supportsAudio = 'audio' in registerRequest && !!registerRequest.audio; + this.supportsVideo = 'video' in registerRequest && !!registerRequest.video; + + const onUpdate = () => this.updateTracker?.onUpdate(); + this.audioContext = new _liveCompositorInternals.AudioContext(onUpdate); + this.internalInputStreamStore = new _liveCompositorInternals.OfflineInputStreamStore(); + this.timeContext = new _liveCompositorInternals.OfflineTimeContext( + onUpdate, + (timestamp: number) => { + store.setCurrentTimestamp(timestamp); + this.internalInputStreamStore.setCurrentTimestamp(timestamp); + } + ); + this.childrenLifetimeContext = new _liveCompositorInternals.ChildrenLifetimeContext(() => {}); + + const rootElement = createElement(OutputRootComponent, { + outputContext: new OutputContext(this, this.outputId, store), + outputRoot: root, + outputShutdownStateStore: this.outputShutdownStateStore, + childrenLifetimeContext: this.childrenLifetimeContext, + }); + + this.renderer = new Renderer({ + rootElement, + onUpdate, + idPrefix: `${this.outputId}-`, + }); + } + + public scene(): { video?: Api.Video; audio?: Api.Audio; schedule_time_ms: number } { + const audio = this.supportsAudio + ? intoAudioInputsConfiguration(this.audioContext.getAudioConfig()) + : undefined; + const video = this.supportsVideo ? { root: this.renderer.scene() } : undefined; + return { + video, + audio, + schedule_time_ms: this.timeContext.timestampMs(), + }; + } + + public async scheduleAllUpdates(): Promise { + this.updateTracker = new UpdateTracker(); + + while (this.timeContext.timestampMs() <= (this.durationMs ?? Infinity)) { + while (true) { + await waitForBlockingTasks(this.timeContext); + await this.updateTracker.waitForRenderEnd(); + if (!this.timeContext.isBlocked()) { + break; + } + } + + const scene = this.scene(); + await this.api.updateScene(this.outputId, scene); + + const timestampMs = this.timeContext.timestampMs(); + if (this.childrenLifetimeContext.isDone() && this.durationMs === undefined) { + await this.api.unregisterOutput(OFFLINE_OUTPUT_ID, { schedule_time_ms: timestampMs }); + break; + } + + this.timeContext.setNextTimestamp(); + } + this.outputShutdownStateStore.close(); + } +} + +class OutputContext implements CompositorOutputContext { + public readonly globalInputStreamStore: _liveCompositorInternals.InputStreamStore; + public readonly internalInputStreamStore: _liveCompositorInternals.InputStreamStore; + public readonly audioContext: _liveCompositorInternals.AudioContext; + public readonly timeContext: _liveCompositorInternals.TimeContext; + public readonly outputId: string; + private output: OfflineOutput; + + constructor( + output: OfflineOutput, + outputId: string, + store: _liveCompositorInternals.InputStreamStore + ) { + this.output = output; + this.globalInputStreamStore = store; + this.internalInputStreamStore = output.internalInputStreamStore; + this.audioContext = output.audioContext; + this.timeContext = output.timeContext; + this.outputId = outputId; + } + + public async registerMp4Input( + inputId: number, + registerRequest: RegisterMp4Input + ): Promise<{ videoDurationMs?: number; audioDurationMs?: number }> { + const inputRef = { + type: 'output-local', + outputId: this.outputId, + id: inputId, + } as const; + const offsetMs = this.timeContext.timestampMs(); + const { video_duration_ms: videoDurationMs, audio_duration_ms: audioDurationMs } = + await this.output.api.registerInput(inputRef, { + type: 'mp4', + offset_ms: offsetMs, + ...registerRequest, + }); + this.output.internalInputStreamStore.addInput({ + inputId, + offsetMs, + videoDurationMs, + audioDurationMs, + }); + if (registerRequest.offsetMs) { + this.timeContext.addTimestamp({ timestamp: offsetMs }); + } + if (videoDurationMs) { + this.timeContext.addTimestamp({ + timestamp: (registerRequest.offsetMs ?? 0) + videoDurationMs, + }); + } + if (audioDurationMs) { + this.timeContext.addTimestamp({ + timestamp: (registerRequest.offsetMs ?? 0) + audioDurationMs, + }); + } + return { + videoDurationMs, + audioDurationMs, + }; + } + public async unregisterMp4Input(inputId: number): Promise { + await this.output.api.unregisterInput( + { + type: 'output-local', + outputId: this.outputId, + id: inputId, + }, + { schedule_time_ms: this.timeContext.timestampMs() } + ); + } +} + +async function waitForBlockingTasks(offlineContext: OfflineTimeContext): Promise { + while (offlineContext.isBlocked()) { + await sleep(100); + } +} + +const MAX_NO_UPDATE_TIMEOUT_MS = 200; +const MAX_RENDER_TIMEOUT_MS = 2000; + +/** + * Instance that tracks updates, this utils allows us to + * to monitor when last update happened in the react tree. + * + * If there were no updates for MAX_NO_UPDATE_TIMEOUT_MS or + * MAX_RENDER_TIMEOUT_MS already passed since we started rendering + * specific PTS then assume it's ready to grab a snapshot of a tree + */ +class UpdateTracker { + private promise: Promise = new Promise(() => {}); + private promiseRes: () => void = () => {}; + private updateTimeout: number = -1; + private renderTimeout: number = -1; + + constructor() { + this.promise = new Promise((res, _rej) => { + this.promiseRes = res; + }); + this.onUpdate(); + } + + public onUpdate() { + clearTimeout(this.updateTimeout); + this.updateTimeout = setTimeout(() => { + this.promiseRes(); + }, MAX_NO_UPDATE_TIMEOUT_MS); + } + + public async waitForRenderEnd(): Promise { + this.promise = new Promise((res, _rej) => { + this.promiseRes = res; + }); + clearTimeout(this.renderTimeout); + this.renderTimeout = setTimeout(() => { + console.warn( + "Render for a specific timestamp took too long, make sure you don't have infinite update loop." + ); + this.promiseRes(); + }, MAX_RENDER_TIMEOUT_MS); + await this.promise; + clearTimeout(this.renderTimeout); + clearTimeout(this.updateTimeout); + } +} + +export default OfflineOutput; diff --git a/ts/@live-compositor/core/src/output.ts b/ts/@live-compositor/core/src/output.ts deleted file mode 100644 index ee119ae99..000000000 --- a/ts/@live-compositor/core/src/output.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Outputs } from 'live-compositor'; -import { _liveCompositorInternals, View } from 'live-compositor'; -import type React from 'react'; -import { createElement, useSyncExternalStore } from 'react'; -import type { ApiClient, Api } from './api.js'; -import Renderer from './renderer.js'; -import type { RegisterOutput } from './api/output.js'; -import { intoAudioInputsConfiguration } from './api/output.js'; -import { throttle } from './utils.js'; - -type OutputContext = _liveCompositorInternals.OutputContext; -type InstanceContextStore = _liveCompositorInternals.InstanceContextStore; - -class Output { - api: ApiClient; - outputId: string; - outputCtx: OutputContext; - outputShutdownStateStore: OutputShutdownStateStore; - - shouldUpdateWhenReady: boolean = false; - throttledUpdate: () => void; - videoRenderer?: Renderer; - initialAudioConfig?: Outputs.AudioInputsConfiguration; - - constructor( - outputId: string, - registerRequest: RegisterOutput, - api: ApiClient, - store: InstanceContextStore - ) { - this.api = api; - this.outputId = outputId; - this.outputShutdownStateStore = new OutputShutdownStateStore(); - this.shouldUpdateWhenReady = false; - this.throttledUpdate = () => { - this.shouldUpdateWhenReady = true; - }; - - const hasAudio = 'audio' in registerRequest && !!registerRequest.audio; - if (hasAudio) { - this.initialAudioConfig = registerRequest.audio!.initial ?? { inputs: [] }; - } - - const onUpdate = () => this.throttledUpdate(); - this.outputCtx = new _liveCompositorInternals.OutputContext(onUpdate, hasAudio); - - if (registerRequest.video) { - const rootElement = createElement(OutputRootComponent, { - instanceStore: store, - outputCtx: this.outputCtx, - outputRoot: registerRequest.video.root, - outputShutdownStateStore: this.outputShutdownStateStore, - }); - - this.videoRenderer = new Renderer({ - rootElement, - onUpdate, - idPrefix: `${outputId}-`, - }); - } - } - - public scene(): { video?: Api.Video; audio?: Api.Audio } { - const audio = this.outputCtx.getAudioConfig() ?? this.initialAudioConfig; - return { - video: this.videoRenderer && { root: this.videoRenderer.scene() }, - audio: audio && intoAudioInputsConfiguration(audio), - }; - } - - public close(): void { - this.throttledUpdate = () => {}; - // close will switch a scene to just a , so we need replace `throttledUpdate` - // callback before it is called - this.outputShutdownStateStore.close(); - } - - public async ready() { - this.throttledUpdate = throttle(async () => { - await this.api.updateScene(this.outputId, this.scene()); - }, 30); - if (this.shouldUpdateWhenReady) { - this.throttledUpdate(); - } - } -} - -// External store to share shutdown information between React tree -// and external code that is managing it. -class OutputShutdownStateStore { - private shutdown: boolean = false; - private onChangeCallbacks: Set<() => void> = new Set(); - - public close() { - this.shutdown = true; - this.onChangeCallbacks.forEach(cb => cb()); - } - - // callback for useSyncExternalStore - public getSnapshot = (): boolean => { - return this.shutdown; - }; - - // callback for useSyncExternalStore - public subscribe = (onStoreChange: () => void): (() => void) => { - this.onChangeCallbacks.add(onStoreChange); - return () => { - this.onChangeCallbacks.delete(onStoreChange); - }; - }; -} - -function OutputRootComponent({ - outputRoot, - instanceStore, - outputCtx, - outputShutdownStateStore, -}: { - outputRoot: React.ReactElement; - instanceStore: InstanceContextStore; - outputCtx: OutputContext; - outputShutdownStateStore: OutputShutdownStateStore; -}) { - const shouldShutdown = useSyncExternalStore( - outputShutdownStateStore.subscribe, - outputShutdownStateStore.getSnapshot - ); - - if (shouldShutdown) { - // replace root with view to stop all the dynamic code - return createElement(View, {}); - } - - const reactCtx = { - instanceStore, - outputCtx, - }; - return createElement( - _liveCompositorInternals.LiveCompositorContext.Provider, - { value: reactCtx }, - outputRoot - ); -} - -export default Output; diff --git a/ts/@live-compositor/core/src/renderer.ts b/ts/@live-compositor/core/src/renderer.ts index acabcb230..53cc188ca 100644 --- a/ts/@live-compositor/core/src/renderer.ts +++ b/ts/@live-compositor/core/src/renderer.ts @@ -221,8 +221,16 @@ type RendererOptions = { idPrefix: string; }; +// docs +interface FiberRootNode { + tag: number; // 0 + containerInfo: Renderer; + pendingChildren: LiveCompositorHostComponent[]; + current: any; +} + class Renderer { - private root: any; + public readonly root: FiberRootNode; public readonly onUpdate: () => void; constructor({ rootElement, onUpdate, idPrefix }: RendererOptions) { @@ -246,13 +254,14 @@ class Renderer { // When resetAfterCommit is called `this.root.current` is not updated yet, so we need to rely // on `pendingChildren`. I'm not sure it is always populated, so there is a fallback to // `root.current`. + const rootComponent = this.root.pendingChildren[0] ?? rootHostComponent(this.root.current); return rootComponent.scene(); } } function rootHostComponent(root: any): LiveCompositorHostComponent { - console.error('No pendingChildren found, this might be an error'); + console.error('No pendingChildren found, this might be an error.'); let current = root; while (current) { if (current?.stateNode instanceof LiveCompositorHostComponent) { diff --git a/ts/@live-compositor/core/src/rootComponent.ts b/ts/@live-compositor/core/src/rootComponent.ts new file mode 100644 index 000000000..475464826 --- /dev/null +++ b/ts/@live-compositor/core/src/rootComponent.ts @@ -0,0 +1,85 @@ +import { _liveCompositorInternals, useAfterTimestamp, View } from 'live-compositor'; +import { createElement, useEffect, useSyncExternalStore, type ReactElement } from 'react'; + +type CompositorOutputContext = _liveCompositorInternals.CompositorOutputContext; +type ChildrenLifetimeContext = _liveCompositorInternals.ChildrenLifetimeContext; + +// External store to share shutdown information between React tree +// and external code that is managing it. +export class OutputShutdownStateStore { + private shutdown: boolean = false; + private onChangeCallbacks: Set<() => void> = new Set(); + + public close() { + this.shutdown = true; + this.onChangeCallbacks.forEach(cb => cb()); + } + + // callback for useSyncExternalStore + public getSnapshot = (): boolean => { + return this.shutdown; + }; + + // callback for useSyncExternalStore + public subscribe = (onStoreChange: () => void): (() => void) => { + this.onChangeCallbacks.add(onStoreChange); + return () => { + this.onChangeCallbacks.delete(onStoreChange); + }; + }; +} + +const globalDelayRef = Symbol(); + +export function OutputRootComponent({ + outputContext, + outputRoot, + outputShutdownStateStore, + childrenLifetimeContext, +}: { + outputContext: CompositorOutputContext; + outputRoot: ReactElement; + outputShutdownStateStore: OutputShutdownStateStore; + childrenLifetimeContext: ChildrenLifetimeContext; +}) { + const shouldShutdown = useSyncExternalStore( + outputShutdownStateStore.subscribe, + outputShutdownStateStore.getSnapshot + ); + + useMinimalStreamDuration(childrenLifetimeContext); + + if (shouldShutdown) { + // replace root with view to stop all the dynamic code + return createElement(View, {}); + } + + return createElement( + _liveCompositorInternals.LiveCompositorContext.Provider, + { value: outputContext }, + createElement( + _liveCompositorInternals.ChildrenLifetimeContextType.Provider, + { value: childrenLifetimeContext }, + outputRoot + ) + ); +} + +/** + * Add minimal 1 second lifetime in case there are not live + * components inside the scene. + */ +function useMinimalStreamDuration(childrenLifetimeContext: ChildrenLifetimeContext) { + useEffect(() => { + childrenLifetimeContext.removeRef(globalDelayRef); + return () => { + childrenLifetimeContext.removeRef(globalDelayRef); + }; + }, []); + const afterTimestamp = useAfterTimestamp(1000); + useEffect(() => { + if (afterTimestamp) { + childrenLifetimeContext.removeRef(globalDelayRef); + } + }, [afterTimestamp]); +} diff --git a/ts/@live-compositor/core/tsconfig.cjs.json b/ts/@live-compositor/core/tsconfig.cjs.json index 79d0bd8c5..6e990d1df 100644 --- a/ts/@live-compositor/core/tsconfig.cjs.json +++ b/ts/@live-compositor/core/tsconfig.cjs.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "target": "ES2016", + "target": "es2017", "outDir": "cjs", "module": "commonjs", "moduleResolution": "node", diff --git a/ts/@live-compositor/core/tsconfig.json b/ts/@live-compositor/core/tsconfig.json index eb9bfd6cf..7c216e7d8 100644 --- a/ts/@live-compositor/core/tsconfig.json +++ b/ts/@live-compositor/core/tsconfig.json @@ -2,5 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "esm" - } + }, + "include": ["src"] } diff --git a/ts/@live-compositor/node/src/index.ts b/ts/@live-compositor/node/src/index.ts index 60614db7f..38eb2f064 100644 --- a/ts/@live-compositor/node/src/index.ts +++ b/ts/@live-compositor/node/src/index.ts @@ -1,5 +1,8 @@ import type { CompositorManager } from '@live-compositor/core'; -import { LiveCompositor as CoreLiveCompositor } from '@live-compositor/core'; +import { + LiveCompositor as CoreLiveCompositor, + OfflineCompositor as CoreOfflineCompositor, +} from '@live-compositor/core'; import LocallySpawnedInstance from './manager/locallySpawnedInstance'; import ExistingInstance from './manager/existingInstance'; @@ -10,3 +13,9 @@ export default class LiveCompositor extends CoreLiveCompositor { super(manager ?? LocallySpawnedInstance.defaultManager()); } } + +export class OfflineCompositor extends CoreOfflineCompositor { + constructor(manager?: CompositorManager) { + super(manager ?? LocallySpawnedInstance.defaultManager()); + } +} diff --git a/ts/@live-compositor/node/src/manager/existingInstance.ts b/ts/@live-compositor/node/src/manager/existingInstance.ts index 3ea881de4..97aa3fb50 100644 --- a/ts/@live-compositor/node/src/manager/existingInstance.ts +++ b/ts/@live-compositor/node/src/manager/existingInstance.ts @@ -1,4 +1,4 @@ -import type { ApiRequest, CompositorManager } from '@live-compositor/core'; +import type { ApiRequest, CompositorManager, SetupInstanceOptions } from '@live-compositor/core'; import { sendRequest } from '../fetch'; import { retry, sleep } from '../utils'; @@ -28,7 +28,9 @@ class ExistingInstance implements CompositorManager { this.wsConnection = new WebSocketConnection(`${wsProtocol}://${this.ip}:${this.port}/ws`); } - public async setupInstance(): Promise { + public async setupInstance(_opts: SetupInstanceOptions): Promise { + // TODO: verify if options match + // https://github.com/software-mansion/live-compositor/issues/877 await retry(async () => { await sleep(500); return await this.sendRequest({ diff --git a/ts/@live-compositor/node/src/manager/locallySpawnedInstance.ts b/ts/@live-compositor/node/src/manager/locallySpawnedInstance.ts index 2d97f06e7..0ce7d9ee6 100644 --- a/ts/@live-compositor/node/src/manager/locallySpawnedInstance.ts +++ b/ts/@live-compositor/node/src/manager/locallySpawnedInstance.ts @@ -4,7 +4,7 @@ import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs-extra'; import * as tar from 'tar'; -import type { ApiRequest, CompositorManager } from '@live-compositor/core'; +import type { ApiRequest, CompositorManager, SetupInstanceOptions } from '@live-compositor/core'; import { download, sendRequest } from '../fetch'; import { retry, sleep } from '../utils'; @@ -48,18 +48,22 @@ class LocallySpawnedInstance implements CompositorManager { }); } - public async setupInstance(): Promise { + public async setupInstance(opts: SetupInstanceOptions): Promise { const executablePath = this.executablePath ?? (await prepareExecutable(this.enableWebRenderer)); spawn(executablePath, [], { env: { - ...process.env, LIVE_COMPOSITOR_DOWNLOAD_DIR: path.join(this.workingdir, 'download'), LIVE_COMPOSITOR_API_PORT: this.port.toString(), LIVE_COMPOSITOR_WEB_RENDERER_ENABLE: this.enableWebRenderer ? 'true' : 'false', // silence scene updates logging + LIVE_COMPOSITOR_LOGGER_FORMAT: 'compact', LIVE_COMPOSITOR_LOGGER_LEVEL: - 'info,wgpu_hal=warn,wgpu_core=warn,compositor_pipeline::pipeline=warn', + 'info,wgpu_hal=warn,wgpu_core=warn,compositor_pipeline::pipeline=warn,live_compositor::log_request_body=debug', + LIVE_COMPOSITOR_AHEAD_OF_TIME_PROCESSING_ENABLE: opts.aheadOfTimeProcessing + ? 'true' + : 'false', + ...process.env, }, }).catch(err => { console.error('LiveCompositor instance failed', err); diff --git a/ts/@live-compositor/web-wasm/src/compositor.ts b/ts/@live-compositor/web-wasm/src/compositor.ts index 18beb9bb8..153baf2e1 100644 --- a/ts/@live-compositor/web-wasm/src/compositor.ts +++ b/ts/@live-compositor/web-wasm/src/compositor.ts @@ -6,6 +6,7 @@ import { intoRegisterOutput } from './output/registerOutput'; import type { RegisterInput } from './input/registerInput'; import { intoRegisterInput } from './input/registerInput'; import type { RegisterImage } from './renderers'; +import type { ReactElement } from 'react'; export type LiveCompositorOptions = { framerate?: Framerate; @@ -44,8 +45,12 @@ export default class LiveCompositor { await this.coreCompositor!.init(); } - public async registerOutput(outputId: string, request: RegisterOutput): Promise { - await this.coreCompositor!.registerOutput(outputId, intoRegisterOutput(request)); + public async registerOutput( + outputId: string, + root: ReactElement, + request: RegisterOutput + ): Promise { + await this.coreCompositor!.registerOutput(outputId, root, intoRegisterOutput(request)); } public async unregisterOutput(outputId: string): Promise { diff --git a/ts/@live-compositor/web-wasm/src/eventSender.ts b/ts/@live-compositor/web-wasm/src/eventSender.ts index 5bcd33758..4758fa35c 100644 --- a/ts/@live-compositor/web-wasm/src/eventSender.ts +++ b/ts/@live-compositor/web-wasm/src/eventSender.ts @@ -1,5 +1,7 @@ -import type { CompositorEvent } from 'live-compositor'; -import { CompositorEventType } from 'live-compositor'; +import { _liveCompositorInternals } from 'live-compositor'; + +export const CompositorEventType = _liveCompositorInternals.CompositorEventType; +export const inputRefIntoRawId = _liveCompositorInternals.inputRefIntoRawId; export class EventSender { private eventCallback?: (event: object) => void; @@ -8,7 +10,7 @@ export class EventSender { this.eventCallback = eventCallback; } - public sendEvent(event: CompositorEvent) { + public sendEvent(event: WasmCompositorEvent) { if (!this.eventCallback) { console.warn(`Failed to send event: ${event}`); return; @@ -18,7 +20,7 @@ export class EventSender { } } -function toWebSocketMessage(event: CompositorEvent): WebSocketMessage { +function toWebSocketMessage(event: WasmCompositorEvent): WebSocketMessage { if (event.type == CompositorEventType.OUTPUT_DONE) { return { type: event.type, @@ -32,32 +34,33 @@ function toWebSocketMessage(event: CompositorEvent): WebSocketMessage { }; } -export type WebSocketMessage = - | { - type: CompositorEventType.AUDIO_INPUT_DELIVERED; - input_id: string; - } - | { - type: CompositorEventType.VIDEO_INPUT_DELIVERED; - input_id: string; - } +export type WasmCompositorEvent = | { - type: CompositorEventType.AUDIO_INPUT_PLAYING; - input_id: string; - } - | { - type: CompositorEventType.VIDEO_INPUT_PLAYING; - input_id: string; + type: + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_DELIVERED + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_DELIVERED + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_PLAYING + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_PLAYING + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_EOS + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_EOS; + inputId: string; } | { - type: CompositorEventType.AUDIO_INPUT_EOS; - input_id: string; - } + type: _liveCompositorInternals.CompositorEventType.OUTPUT_DONE; + outputId: string; + }; +export type WebSocketMessage = | { - type: CompositorEventType.VIDEO_INPUT_EOS; + type: + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_DELIVERED + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_DELIVERED + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_PLAYING + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_PLAYING + | _liveCompositorInternals.CompositorEventType.AUDIO_INPUT_EOS + | _liveCompositorInternals.CompositorEventType.VIDEO_INPUT_EOS; input_id: string; } | { - type: CompositorEventType.OUTPUT_DONE; + type: _liveCompositorInternals.CompositorEventType.OUTPUT_DONE; output_id: string; }; diff --git a/ts/@live-compositor/web-wasm/src/input/input.ts b/ts/@live-compositor/web-wasm/src/input/input.ts index 0e63432c4..616641529 100644 --- a/ts/@live-compositor/web-wasm/src/input/input.ts +++ b/ts/@live-compositor/web-wasm/src/input/input.ts @@ -1,6 +1,5 @@ import type { InputId } from '@live-compositor/browser-render'; -import { CompositorEventType } from 'live-compositor'; -import type { EventSender } from '../eventSender'; +import { CompositorEventType, type EventSender } from '../eventSender'; import type InputSource from './source'; import { Queue } from '@datastructures-js/queue'; import { H264Decoder } from './decoder/h264Decoder'; diff --git a/ts/@live-compositor/web-wasm/src/output/registerOutput.ts b/ts/@live-compositor/web-wasm/src/output/registerOutput.ts index c40682280..e505089e1 100644 --- a/ts/@live-compositor/web-wasm/src/output/registerOutput.ts +++ b/ts/@live-compositor/web-wasm/src/output/registerOutput.ts @@ -1,13 +1,11 @@ import type { Resolution } from '@live-compositor/browser-render'; import type { RegisterOutput as InternalRegisterOutput } from '@live-compositor/core'; -import type { ReactElement } from 'react'; export type RegisterOutput = { type: 'canvas' } & RegisterCanvasOutput; export type RegisterCanvasOutput = { resolution: Resolution; canvas: HTMLCanvasElement; - root: ReactElement; }; export function intoRegisterOutput(output: RegisterOutput): InternalRegisterOutput { @@ -24,7 +22,6 @@ function fromRegisterCanvasOutput(output: RegisterCanvasOutput): InternalRegiste video: { resolution: output.resolution, canvas: output.canvas, - root: output.root, }, }; } diff --git a/ts/create-live-compositor/templates/node-express-zustand/src/compositor.tsx b/ts/create-live-compositor/templates/node-express-zustand/src/compositor.tsx index 07aa59170..7fbba3597 100644 --- a/ts/create-live-compositor/templates/node-express-zustand/src/compositor.tsx +++ b/ts/create-live-compositor/templates/node-express-zustand/src/compositor.tsx @@ -10,7 +10,7 @@ export async function initializeCompositor() { // Display output with `ffplay`. await ffplayStartPlayerAsync('127.0.0.0', 8001); - await Compositor.registerOutput('output_1', { + await Compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, ip: '127.0.0.1', @@ -24,7 +24,6 @@ export async function initializeCompositor() { width: 1920, height: 1080, }, - root: , }, }); diff --git a/ts/create-live-compositor/templates/node-minimal/src/index.tsx b/ts/create-live-compositor/templates/node-minimal/src/index.tsx index 61562af6d..0edd2fe9e 100644 --- a/ts/create-live-compositor/templates/node-minimal/src/index.tsx +++ b/ts/create-live-compositor/templates/node-minimal/src/index.tsx @@ -25,7 +25,7 @@ async function run() { // Display output with `ffplay`. await ffplayStartPlayerAsync('127.0.0.0', 8001); - await compositor.registerOutput('output_1', { + await compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, ip: '127.0.0.1', @@ -39,7 +39,6 @@ async function run() { width: 1920, height: 1080, }, - root: , }, }); diff --git a/ts/examples/node-examples/src/audio.tsx b/ts/examples/node-examples/src/audio.tsx index 8234739aa..4f5786fd7 100644 --- a/ts/examples/node-examples/src/audio.tsx +++ b/ts/examples/node-examples/src/audio.tsx @@ -56,7 +56,7 @@ async function run() { await sleep(2000); - await compositor.registerOutput('output_1', { + await compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, transportProtocol: 'tcp_server', @@ -69,7 +69,6 @@ async function run() { width: 1920, height: 1080, }, - root: , }, audio: { encoder: { diff --git a/ts/examples/node-examples/src/combine_mp4.tsx b/ts/examples/node-examples/src/combine_mp4.tsx new file mode 100644 index 000000000..12c70d7cb --- /dev/null +++ b/ts/examples/node-examples/src/combine_mp4.tsx @@ -0,0 +1,90 @@ +import { OfflineCompositor } from '@live-compositor/node'; +import { + View, + Text, + Tiles, + Rescaler, + InputStream, + useCurrentTimestamp, + useAfterTimestamp, + Show, +} from 'live-compositor'; +import { downloadAllAssets } from './utils'; +import path from 'path'; +import { useState } from 'react'; + +function ExampleApp() { + return ( + + + + + + + + + + ); +} + +function InputTile({ inputId }: { inputId: string }) { + const currentTimestamp = useCurrentTimestamp(); + const [mountTime, _setMountTime] = useState(() => currentTimestamp); + const afterDelay = useAfterTimestamp(mountTime + 1000); + + const bottom = afterDelay ? 10 : 1080; + return ( + + + + + + + Input ID: {inputId} + + + + ); +} + +async function run() { + await downloadAllAssets(); + const compositor = new OfflineCompositor(); + await compositor.init(); + + await compositor.registerInput('input_1', { + type: 'mp4', + serverPath: path.join(__dirname, '../.assets/BigBuckBunny.mp4'), + offsetMs: 0, + required: true, + }); + + await compositor.registerInput('input_2', { + type: 'mp4', + serverPath: path.join(__dirname, '../.assets/ElephantsDream.mp4'), + offsetMs: 0, + required: true, + }); + + await compositor.render( + , + { + type: 'mp4', + serverPath: path.join(__dirname, '../.assets/combing_mp4_output.mp4'), + video: { + encoder: { + type: 'ffmpeg_h264', + preset: 'ultrafast', + }, + resolution: { + width: 1920, + height: 1080, + }, + }, + }, + 10000 + ); + process.exit(0); +} +void run(); diff --git a/ts/examples/node-examples/src/concat_mp4.tsx b/ts/examples/node-examples/src/concat_mp4.tsx new file mode 100644 index 000000000..ff63fdd08 --- /dev/null +++ b/ts/examples/node-examples/src/concat_mp4.tsx @@ -0,0 +1,132 @@ +import { OfflineCompositor } from '@live-compositor/node'; +import { View, Text, Rescaler, SlideShow, Slide, Mp4, InputStream } from 'live-compositor'; +import { downloadAllAssets } from './utils'; +import path from 'path'; +import type { ReactElement } from 'react'; + +function ExampleApp() { + return ( + + + + + + + + + + + + + + + ); +} + +function ExampleScene() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TitleSlide(props: { text: string }) { + return ( + + + {props.text} + + + ); +} + +function SlideWithLabel({ label, children }: { label: string; children: ReactElement }) { + return ( + + {children} + + + {label} + + + + ); +} + +async function run() { + await downloadAllAssets(); + const compositor = new OfflineCompositor(); + await compositor.init(); + + await compositor.registerInput('input_1', { + type: 'mp4', + serverPath: path.join(__dirname, '../.assets/BigBuckBunny.mp4'), + offsetMs: 0, + required: true, + }); + + await compositor.render(, { + type: 'mp4', + serverPath: path.join(__dirname, '../.assets/concat_mp4_output.mp4'), + video: { + encoder: { + type: 'ffmpeg_h264', + preset: 'ultrafast', + }, + resolution: { + width: 1920, + height: 1080, + }, + }, + audio: { + encoder: { + type: 'aac', + channels: 'stereo', + }, + }, + }); + process.exit(0); +} +void run(); diff --git a/ts/examples/node-examples/src/dynamic-inputs.tsx b/ts/examples/node-examples/src/dynamic-inputs.tsx index 11fbf818c..d60b3ffe7 100644 --- a/ts/examples/node-examples/src/dynamic-inputs.tsx +++ b/ts/examples/node-examples/src/dynamic-inputs.tsx @@ -50,7 +50,7 @@ async function run() { void ffplayStartPlayerAsync('127.0.0.1', 8001); await sleep(2000); - await compositor.registerOutput('output_1', { + await compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, ip: '127.0.0.1', @@ -64,7 +64,6 @@ async function run() { width: 1920, height: 1080, }, - root: , }, }); await compositor.start(); diff --git a/ts/examples/node-examples/src/dynamic-outputs.tsx b/ts/examples/node-examples/src/dynamic-outputs.tsx index 0cb4c34a5..a0c5c46da 100644 --- a/ts/examples/node-examples/src/dynamic-outputs.tsx +++ b/ts/examples/node-examples/src/dynamic-outputs.tsx @@ -46,14 +46,13 @@ async function run() { preset: 'ultrafast', } as const; - await compositor.registerOutput('output_stream', { + await compositor.registerOutput('output_stream', , { type: 'rtp_stream', port: 8001, transportProtocol: 'tcp_server', video: { encoder: VIDEO_ENCODER_OPTS, resolution: RESOLUTION, - root: , }, audio: { encoder: { @@ -63,13 +62,12 @@ async function run() { }, }); void gstReceiveTcpStream('127.0.0.1', 8001); - await compositor.registerOutput('output_recording', { + await compositor.registerOutput('output_recording', , { type: 'mp4', serverPath: path.join(__dirname, '../.workingdir/dynamic_outputs_recording.mp4'), video: { encoder: VIDEO_ENCODER_OPTS, resolution: RESOLUTION, - root: , }, audio: { encoder: { @@ -94,13 +92,12 @@ async function run() { type: 'mp4', serverPath: path.join(__dirname, '../.assets/ElephantsDream.mp4'), }); - await compositor.registerOutput('output_recording_part2', { + await compositor.registerOutput('output_recording_part2', , { type: 'mp4', serverPath: path.join(__dirname, '../.workingdir/dynamic_outputs_recording_10s.mp4'), video: { encoder: VIDEO_ENCODER_OPTS, resolution: RESOLUTION, - root: , }, audio: { encoder: { diff --git a/ts/examples/node-examples/src/dynamic-text.tsx b/ts/examples/node-examples/src/dynamic-text.tsx index 17dc13d9b..4a6b3ab2e 100644 --- a/ts/examples/node-examples/src/dynamic-text.tsx +++ b/ts/examples/node-examples/src/dynamic-text.tsx @@ -58,7 +58,7 @@ async function run() { void ffplayStartPlayerAsync('127.0.0.1', 8001); await sleep(2000); - await compositor.registerOutput('output_1', { + await compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, ip: '127.0.0.1', @@ -72,7 +72,6 @@ async function run() { width: 1920, height: 1080, }, - root: , }, }); diff --git a/ts/examples/node-examples/src/simple.tsx b/ts/examples/node-examples/src/simple.tsx index d65eca2ac..12873aec9 100644 --- a/ts/examples/node-examples/src/simple.tsx +++ b/ts/examples/node-examples/src/simple.tsx @@ -47,7 +47,7 @@ async function run() { void ffplayStartPlayerAsync('127.0.0.1', 8001); await sleep(2000); - await compositor.registerOutput('output_1', { + await compositor.registerOutput('output_1', , { type: 'rtp_stream', port: 8001, ip: '127.0.0.1', @@ -61,7 +61,6 @@ async function run() { width: 1920, height: 1080, }, - root: , }, }); await compositor.start(); diff --git a/ts/examples/vite-browser-render/src/examples/MP4Player.tsx b/ts/examples/vite-browser-render/src/examples/MP4Player.tsx index fef317767..d30008737 100644 --- a/ts/examples/vite-browser-render/src/examples/MP4Player.tsx +++ b/ts/examples/vite-browser-render/src/examples/MP4Player.tsx @@ -84,14 +84,13 @@ function useCompositor(): [LiveCompositor | undefined, (canvas: HTMLCanvasElemen 'https://fonts.gstatic.com/s/notosans/v36/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A-9a6Vc.ttf' ); void compositor.registerInput('bunny_video', { type: 'mp4', url: BUNNY_URL }); - await compositor.registerOutput('output', { + await compositor.registerOutput('output', , { type: 'canvas', canvas: canvas, resolution: { width: 1280, height: 720, }, - root: , }); }; diff --git a/ts/live-compositor/src/components/InputStream.ts b/ts/live-compositor/src/components/InputStream.ts index 833b6c0ef..796a2cf25 100644 --- a/ts/live-compositor/src/components/InputStream.ts +++ b/ts/live-compositor/src/components/InputStream.ts @@ -1,8 +1,11 @@ -import { createElement } from 'react'; +import { createElement, useContext, useEffect, useState } from 'react'; import type * as Api from '../api.js'; import type { SceneComponent } from '../component.js'; import { createCompositorComponent } from '../component.js'; -import { useAudioInput } from '../hooks.js'; +import { useAudioInput, useInputStreams } from '../hooks.js'; +import { useTimeLimitedComponent } from '../context/childrenLifetimeContext.js'; +import { LiveCompositorContext } from '../context/index.js'; +import { inputRefIntoRawId } from '../internal.js'; export type InputStreamProps = { children?: undefined; @@ -25,17 +28,37 @@ export type InputStreamProps = { muted?: boolean; }; -type AudioPropNames = 'muted' | 'volume' | 'disableAudioControl'; +type AudioPropNames = 'muted' | 'volume'; -const InnerInputStream = +export const InnerInputStream = createCompositorComponent>(sceneBuilder); function InputStream(props: InputStreamProps) { - const { muted, volume, ...otherProps } = props; - useAudioInput(props.inputId, { + const { muted, volume, inputId, ...otherProps } = props; + useAudioInput(inputId, { volume: muted ? 0 : (volume ?? 1), }); - return createElement(InnerInputStream, otherProps); + useTimeLimitedInputStream(inputId); + return createElement(InnerInputStream, { + ...otherProps, + inputId: inputRefIntoRawId({ type: 'global', id: inputId }), + }); +} + +function useTimeLimitedInputStream(inputId: string) { + const ctx = useContext(LiveCompositorContext); + + // startTime is only needed for live case. In offline + // mode offset is always set. + const [startTime, setStartTime] = useState(0); + useEffect(() => { + setStartTime(ctx.timeContext.timestampMs()); + }, [inputId]); + + const inputs = useInputStreams(); + const input = inputs[inputId]; + useTimeLimitedComponent((input?.offsetMs ?? startTime) + (input?.videoDurationMs ?? 0)); + useTimeLimitedComponent((input?.offsetMs ?? startTime) + (input?.audioDurationMs ?? 0)); } function sceneBuilder(props: InputStreamProps, _children: SceneComponent[]): Api.Component { diff --git a/ts/live-compositor/src/components/Mp4.ts b/ts/live-compositor/src/components/Mp4.ts new file mode 100644 index 000000000..be76e7866 --- /dev/null +++ b/ts/live-compositor/src/components/Mp4.ts @@ -0,0 +1,112 @@ +import { createElement, useContext, useEffect, useState, useSyncExternalStore } from 'react'; +import type * as Api from '../api.js'; +import { newBlockingTask } from '../hooks.js'; +import { useTimeLimitedComponent } from '../context/childrenLifetimeContext.js'; +import { LiveCompositorContext } from '../context/index.js'; +import { inputRefIntoRawId, OfflineTimeContext } from '../internal.js'; +import { InnerInputStream } from './InputStream.js'; +import { newInternalStreamId } from '../context/internalStreamStore.js'; + +export type Mp4Props = { + children?: undefined; + + /** + * Id of a component. + */ + id?: Api.ComponentId; + /** + * Audio volume represented by a number between 0 and 1. + */ + volume?: number; + /** + * Mute audio. + */ + muted?: boolean; + + /** + * Url or path to the mp4 file. File path refers to the filesystem where LiveCompositor server is deployed. + */ + source: string; +}; + +function Mp4(props: Mp4Props) { + const { muted, volume, ...otherProps } = props; + const ctx = useContext(LiveCompositorContext); + const [inputId, setInputId] = useState(0); + + useEffect(() => { + const newInputId = newInternalStreamId(); + setInputId(newInputId); + const task = newBlockingTask(ctx); + const pathOrUrl = + props.source.startsWith('http://') || props.source.startsWith('https://') + ? { url: props.source } + : { path: props.source }; + let registerPromise: Promise; + void (async () => { + try { + registerPromise = ctx.registerMp4Input(newInputId, { + ...pathOrUrl, + required: ctx.timeContext instanceof OfflineTimeContext, + // offsetMs will be overridden by registerMp4Input implementation + }); + await registerPromise; + } finally { + task.done(); + } + })(); + return () => { + task.done(); + void (async () => { + await registerPromise.catch(() => {}); + await ctx.unregisterMp4Input(newInputId); + })(); + }; + }, [props.source]); + + useInternalAudioInput(inputId, muted ? 0 : (volume ?? 1)); + useTimeLimitedMp4(inputId); + + return createElement(InnerInputStream, { + ...otherProps, + inputId: inputRefIntoRawId({ type: 'output-local', id: inputId, outputId: ctx.outputId }), + }); +} + +function useInternalAudioInput(inputId: number, volume: number) { + const ctx = useContext(LiveCompositorContext); + useEffect(() => { + if (inputId === 0) { + return; + } + const options = { volume }; + ctx.audioContext.addInputAudioComponent( + { type: 'output-local', id: inputId, outputId: ctx.outputId }, + options + ); + return () => { + ctx.audioContext.removeInputAudioComponent( + { type: 'output-local', id: inputId, outputId: ctx.outputId }, + options + ); + }; + }, [inputId, volume]); +} + +function useTimeLimitedMp4(inputId: number) { + const ctx = useContext(LiveCompositorContext); + const [startTime, setStartTime] = useState(0); + useEffect(() => { + setStartTime(ctx.timeContext.timestampMs()); + }, [inputId]); + + const internalStreams = useSyncExternalStore( + ctx.internalInputStreamStore.subscribe, + ctx.internalInputStreamStore.getSnapshot + ); + const input = internalStreams[String(inputId)]; + useTimeLimitedComponent((input?.offsetMs ?? startTime) + (input?.videoDurationMs ?? 0)); + useTimeLimitedComponent((input?.offsetMs ?? startTime) + (input?.audioDurationMs ?? 0)); +} + +export default Mp4; diff --git a/ts/live-compositor/src/components/Show.ts b/ts/live-compositor/src/components/Show.ts new file mode 100644 index 000000000..6054955ff --- /dev/null +++ b/ts/live-compositor/src/components/Show.ts @@ -0,0 +1,39 @@ +import type React from 'react'; + +import { useAfterTimestamp } from '../hooks.js'; +import { LiveCompositorContext } from '../context/index.js'; +import { useContext, useEffect, useState } from 'react'; + +export type ShowProps = { + timeRangeMs?: { start?: number; end?: number }; + delayMs?: number; + children?: React.ReactNode; +}; + +function Show(props: ShowProps) { + if ('delayMs' in props && props.timeRangeMs) { + throw new Error('"delayMs" and "timestamp" props can\'t be specified at the same time.'); + } + if (props.timeRangeMs && !props.timeRangeMs.start && !props.timeRangeMs.end) { + throw new Error('"timestampMs" prop needs to define at least one value "start" or "end".'); + } + const ctx = useContext(LiveCompositorContext); + const [mountTimestampMs, setStart] = useState(() => ctx.timeContext.timestampMs()); + const afterStart = useAfterTimestamp(props.timeRangeMs?.start ?? 0); + const afterEnd = useAfterTimestamp(props.timeRangeMs?.end ?? Infinity); + const isAfterDelay = useAfterTimestamp(mountTimestampMs + (props.delayMs ?? 0)); + + useEffect(() => { + setStart(ctx.timeContext.timestampMs()); + }, []); + + if (props.delayMs !== undefined && isAfterDelay) { + return props.children; + } else if (props.timeRangeMs && afterStart && !afterEnd) { + return props.children; + } else { + return null; + } +} + +export default Show; diff --git a/ts/live-compositor/src/components/SlideShow.ts b/ts/live-compositor/src/components/SlideShow.ts new file mode 100644 index 000000000..a6d2cf91b --- /dev/null +++ b/ts/live-compositor/src/components/SlideShow.ts @@ -0,0 +1,108 @@ +import type React from 'react'; +import type { ReactElement } from 'react'; +import { Children, createElement, useEffect, useRef, useState, useCallback } from 'react'; + +import { useCurrentTimestamp } from '../hooks.js'; +import View from './View.js'; +import { + ChildrenLifetimeContext, + ChildrenLifetimeContextType, + useCompletableComponent, + useTimeLimitedComponent, +} from '../context/childrenLifetimeContext.js'; + +export type SlideProps = { + /** + * Duration in milliseconds how long this component should be shown. + * If not specified defaults to value of an Input stream + */ + durationMs?: number; + children?: React.ReactNode; +}; + +export type SlideShowProps = { + children: React.ReactNode; +}; + +export function SlideShow(props: SlideShowProps) { + const prevChildrenRef = useRef(); + const [childIndex, setChildIndex] = useState(0); + + const childrenArray = Children.toArray(props.children); + + for (const slide of childrenArray) { + if ((slide as ReactElement).type !== Slide) { + throw new Error('SlideShow component only accepts as children'); + } + } + + useEffect(() => { + // If "current" element was removed we should compare it to the next elements + // in the old list. + const prevChildrenOrder = Children.toArray(prevChildrenRef.current).slice(childIndex); + const newChildren = Children.toArray(props.children); + + for (const prev of prevChildrenOrder) { + for (const [index, newChild] of newChildren.entries()) { + if ((newChild as ReactElement).key === (prev as ReactElement).key) { + if (childIndex !== index) { + setChildIndex(index); + } + prevChildrenRef.current = props.children; + return; + } + } + } + + // If nothing from old list is left then just use the same child index + prevChildrenRef.current = props.children; + }, [props.children]); + + const [shouldCheckChildren, setShouldCheckChildren] = useState(false); + const onChildrenChange = useCallback(() => { + setShouldCheckChildren(true); + }, []); + const [slideContext, _setSlideCtx] = useState( + () => new ChildrenLifetimeContext(onChildrenChange) + ); + + useEffect(() => { + if (shouldCheckChildren) { + setShouldCheckChildren(false); + if (slideContext.isDone()) { + setChildIndex(childIndex + 1); + } + } + }, [shouldCheckChildren]); + + // report this SlideShow lifetime to its parents (to support nested SlideShows) + useCompletableComponent(childIndex >= childrenArray.length); + + return createElement( + ChildrenLifetimeContextType.Provider, + { value: slideContext }, + childrenArray[childIndex] ?? createElement(View, {}) + ); +} + +export function Slide(props: SlideProps) { + const [slideContext, _setSlideCtx] = useState(() => new ChildrenLifetimeContext(() => {})); + const currentTimestamp = useCurrentTimestamp(); + const [initTimestamp, _setInitTimestamp] = useState(currentTimestamp); + + // defaults to 1 second + const durationMs = + props.durationMs !== undefined && props.durationMs !== null ? props.durationMs : 1000; + + useTimeLimitedComponent(initTimestamp + durationMs); + if (props.durationMs) { + // Add fake context if durationMs is specified to ignore child components + return createElement( + ChildrenLifetimeContextType.Provider, + { value: slideContext }, + props.children + ); + } else { + return props.children; + } +} diff --git a/ts/live-compositor/src/context/audioOutputContext.ts b/ts/live-compositor/src/context/audioOutputContext.ts new file mode 100644 index 000000000..af5e35155 --- /dev/null +++ b/ts/live-compositor/src/context/audioOutputContext.ts @@ -0,0 +1,64 @@ +import type { InputRef } from '../types/inputRef.js'; +import { areInputRefsEqual } from '../types/inputRef.js'; + +export type ContextAudioOptions = { + volume: number; +}; + +export type AudioMixerState = AudioInputConfig[]; + +export type AudioInputConfig = { + inputRef: InputRef; + volumeComponents: ContextAudioOptions[]; +}; + +export type AudioConfig = Array<{ inputRef: InputRef; volume: number }>; + +export class AudioContext { + private audioMixerConfig: AudioMixerState; + private onChange: () => void; + + constructor(onChange: () => void) { + this.audioMixerConfig = []; + this.onChange = onChange; + } + + public getAudioConfig(): AudioConfig { + return this.audioMixerConfig.map(input => ({ + inputRef: input.inputRef, + volume: Math.min( + input.volumeComponents.reduce((acc, opt) => acc + opt.volume, 0), + 1.0 + ), + })); + } + + public addInputAudioComponent(inputRef: InputRef, options: ContextAudioOptions) { + const inputConfig = this.audioMixerConfig.find(input => + areInputRefsEqual(input.inputRef, inputRef) + ); + if (inputConfig) { + inputConfig.volumeComponents = [...inputConfig.volumeComponents, options]; + } else { + this.audioMixerConfig = [ + ...this.audioMixerConfig, + { + inputRef, + volumeComponents: [options], + }, + ]; + } + this.onChange(); + } + + public removeInputAudioComponent(inputRef: InputRef, options: ContextAudioOptions) { + const inputConfig = this.audioMixerConfig.find(input => + areInputRefsEqual(input.inputRef, inputRef) + ); + if (inputConfig) { + // opt !== options compares objects by reference + inputConfig.volumeComponents = inputConfig.volumeComponents.filter(opt => opt !== options); + this.onChange(); + } + } +} diff --git a/ts/live-compositor/src/context/childrenLifetimeContext.ts b/ts/live-compositor/src/context/childrenLifetimeContext.ts new file mode 100644 index 000000000..4abfbba66 --- /dev/null +++ b/ts/live-compositor/src/context/childrenLifetimeContext.ts @@ -0,0 +1,75 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { useAfterTimestamp } from '../hooks.js'; + +export class ChildrenLifetimeContext { + private childrenRefs: Set = new Set(); + private onChange: () => void; + + constructor(onChange: () => void) { + this.onChange = onChange; + } + + public addRef(ref: Symbol) { + this.childrenRefs.add(ref); + this.onChange(); + } + + public removeRef(ref: Symbol) { + this.childrenRefs.delete(ref); + this.onChange(); + } + + public isDone(): boolean { + return this.childrenRefs.size === 0; + } +} + +/** + * Context that exposes API to children to register themself as playing/in-progress. Some components + * will change their behavior based on the state of its in-direct children, e.g. Slides component will + * not switch Slide until children are finished. + */ +export const ChildrenLifetimeContextType = createContext(new ChildrenLifetimeContext(() => {})); + +/** + * Internal helper hook that can be use inside other components to propagate + * their duration/lifetime to the parents. + */ +export function useTimeLimitedComponent(timestamp: number) { + const childrenLifetimeContext = useContext(ChildrenLifetimeContextType); + const afterTimestamp = useAfterTimestamp(timestamp); + const [ref, setComponentRef] = useState(); + useEffect(() => { + let ref = Symbol(); + setComponentRef(ref); + childrenLifetimeContext.addRef(ref); + return () => { + childrenLifetimeContext.removeRef(ref); + }; + }, [timestamp]); + + useEffect(() => { + if (ref && afterTimestamp) { + childrenLifetimeContext.removeRef(ref); + } + }, [afterTimestamp, ref]); +} + +export function useCompletableComponent(completed: boolean) { + const childrenLifetimeContext = useContext(ChildrenLifetimeContextType); + const [ref, setComponentRef] = useState(); + useEffect(() => { + let ref = Symbol(); + setComponentRef(ref); + childrenLifetimeContext.addRef(ref); + return () => { + childrenLifetimeContext.removeRef(ref); + }; + }, []); + + useEffect(() => { + if (ref && completed) { + childrenLifetimeContext.removeRef(ref); + } + }, [completed, ref]); +} diff --git a/ts/live-compositor/src/context/index.ts b/ts/live-compositor/src/context/index.ts index da77c93c8..9f632c95a 100644 --- a/ts/live-compositor/src/context/index.ts +++ b/ts/live-compositor/src/context/index.ts @@ -1,17 +1,37 @@ import { createContext } from 'react'; -import { InstanceContextStore } from './instanceContextStore.js'; -import { OutputContext } from './outputContext.js'; +import { AudioContext } from './audioOutputContext.js'; +import type { TimeContext } from './timeContext.js'; +import { LiveTimeContext } from './timeContext.js'; +import { LiveInputStreamStore, type InputStreamStore } from './inputStreamStore.js'; +import type { RegisterMp4Input } from '../types/registerInput.js'; -type CompositorOutputContext = { - // global store for the entire LiveCompositor instance - instanceStore: InstanceContextStore; - // state specific to the current output - outputCtx: OutputContext; +export type CompositorOutputContext = { + // global store for input stream state + globalInputStreamStore: InputStreamStore; + // internal input streams store + internalInputStreamStore: InputStreamStore; + // Audio mixer configuration + audioContext: AudioContext; + // Time tracking and handling for blocking tasks + timeContext: TimeContext; + + outputId: string; + + // TODO: aggregate that into some context object when we add more methods like this. + registerMp4Input: ( + inputId: number, + registerRequest: RegisterMp4Input + ) => Promise<{ videoDurationMs?: number; audioDurationMs?: number }>; + + unregisterMp4Input: (inputId: number) => Promise; }; export const LiveCompositorContext = createContext({ - instanceStore: new InstanceContextStore(), - outputCtx: new OutputContext(() => {}, false), + globalInputStreamStore: new LiveInputStreamStore(), + internalInputStreamStore: new LiveInputStreamStore(), + audioContext: new AudioContext(() => {}), + timeContext: new LiveTimeContext(), + outputId: '', + registerMp4Input: async () => ({}), + unregisterMp4Input: async () => {}, }); - -export { InstanceContextStore, OutputContext }; diff --git a/ts/live-compositor/src/context/inputStreamStore.ts b/ts/live-compositor/src/context/inputStreamStore.ts new file mode 100644 index 000000000..8430d2055 --- /dev/null +++ b/ts/live-compositor/src/context/inputStreamStore.ts @@ -0,0 +1,194 @@ +import { useContext, useState } from 'react'; +import { LiveCompositorContext } from './index.js'; + +let nextStreamNumber = 1; + +/* + * Generates unique input stream id that can be used in e.g. Mp4 component + */ +export function useInternalStreamId(): string { + const ctx = useContext(LiveCompositorContext); + const [streamNumber, _setStreamNumber] = useState(() => { + const result = nextStreamNumber; + nextStreamNumber += 1; + return result; + }); + return `output-local:${streamNumber}:${ctx.outputId}`; +} + +export type StreamState = 'ready' | 'playing' | 'finished'; + +export type InputStreamInfo = { + inputId: Id; + videoState?: StreamState; + audioState?: StreamState; + offsetMs?: number | null; + videoDurationMs?: number; + audioDurationMs?: number; +}; + +type InstanceContext = Record>; + +export interface InputStreamStore { + getSnapshot: () => InstanceContext; + subscribe: (onStoreChange: () => void) => () => void; +} + +type UpdateAction = + | { type: 'update_input'; input: InputStreamInfo } + | { type: 'add_input'; input: InputStreamInfo } + | { type: 'remove_input'; inputId: Id }; + +export class LiveInputStreamStore { + private context: Record> = {}; + private onChangeCallbacks: Set<() => void> = new Set(); + private eventQueue?: UpdateAction[]; + + /** + * Apply update immediately if there are no `runBlocking` calls in progress. + * Otherwise wait for `runBlocking call to finish`. + */ + public dispatchUpdate(update: UpdateAction) { + if (this.eventQueue) { + this.eventQueue.push(update); + } else { + this.applyUpdate(update); + } + } + + /** + * No dispatch events will be processed while `fn` function executes. + * Argument passed to the callback should be used instead of `this.dispatchUpdate` + * to update the store from inside `fn` + */ + public async runBlocking( + fn: (update: (action: UpdateAction) => void) => Promise + ): Promise { + this.eventQueue = []; + try { + return await fn(a => this.applyUpdate(a)); + } finally { + for (const event of this.eventQueue) { + this.applyUpdate(event); + } + this.eventQueue = undefined; + } + } + + private applyUpdate(update: UpdateAction) { + if (update.type === 'add_input') { + this.addInput(update.input); + } else if (update.type === 'update_input') { + this.updateInput(update.input); + } else if (update.type === 'remove_input') { + this.removeInput(update.inputId); + } + } + + private addInput(input: InputStreamInfo) { + if (this.context[String(input.inputId)]) { + console.warn(`Adding input ${input.inputId}. Input already exists.`); + } + this.context = { ...this.context, [String(input.inputId)]: input }; + this.signalUpdate(); + } + + private updateInput(update: InputStreamInfo) { + const oldInput = this.context[String(update.inputId)]; + if (!oldInput) { + console.warn(`Updating input ${update.inputId}. Input does not exist.`); + return; + } + this.context = { + ...this.context, + [String(update.inputId)]: { ...oldInput, ...update }, + }; + this.signalUpdate(); + } + + private removeInput(inputId: Id) { + const context = { ...this.context }; + delete context[String(inputId)]; + this.context = context; + this.signalUpdate(); + } + + private signalUpdate() { + for (const cb of this.onChangeCallbacks) { + cb(); + } + } + + // callback for useSyncExternalStore + public getSnapshot = (): InstanceContext => { + return this.context; + }; + + // callback for useSyncExternalStore + public subscribe = (onStoreChange: () => void): (() => void) => { + this.onChangeCallbacks.add(onStoreChange); + return () => { + this.onChangeCallbacks.delete(onStoreChange); + }; + }; +} + +type OfflineAddInput = { + inputId: Id; + offsetMs: number; + videoDurationMs?: number; + audioDurationMs?: number; +}; + +export class OfflineInputStreamStore { + private context: InstanceContext = {}; + private inputs: OfflineAddInput[] = []; + private onChangeCallbacks: Set<() => void> = new Set(); + + public addInput(update: OfflineAddInput) { + this.inputs.push(update); + } + + // TimeContext should call that function. It will always trigger re-render, but there + // is no point to optimize it right now. + public setCurrentTimestamp(timestampMs: number) { + this.context = Object.fromEntries( + this.inputs + .filter(input => timestampMs >= input.offsetMs) + .map(input => { + // TODO: We could add "unknown" state if Mp4 duration is not known + const inputState = { + inputId: input.inputId, + videoState: + input.offsetMs + (input.videoDurationMs ?? 0) <= timestampMs ? 'finished' : 'playing', + audioState: + input.offsetMs + (input.audioDurationMs ?? 0) <= timestampMs ? 'finished' : 'playing', + videoDurationMs: input.videoDurationMs, + audioDurationMs: input.audioDurationMs, + offsetMs: input.offsetMs, + } as const; + return [input.inputId, inputState]; + }) + ); + this.signalUpdate(); + } + + private signalUpdate() { + for (const cb of this.onChangeCallbacks) { + cb(); + } + } + + // callback for useSyncExternalStore + public getSnapshot = (): InstanceContext => { + return this.context; + }; + + // callback for useSyncExternalStore + public subscribe = (onStoreChange: () => void): (() => void) => { + this.onChangeCallbacks.add(onStoreChange); + return () => { + this.onChangeCallbacks.delete(onStoreChange); + }; + }; +} diff --git a/ts/live-compositor/src/context/instanceContextStore.ts b/ts/live-compositor/src/context/instanceContextStore.ts deleted file mode 100644 index df64b6347..000000000 --- a/ts/live-compositor/src/context/instanceContextStore.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type * as Api from '../api.js'; - -export type StreamState = 'ready' | 'playing' | 'finished'; - -export type InputStreamInfo = { - inputId: string; - videoState?: StreamState; - audioState?: StreamState; -}; - -type UpdateAction = - | { type: 'update_input'; input: InputStreamInfo } - | { type: 'add_input'; input: InputStreamInfo } - | { type: 'remove_input'; inputId: string }; - -type InstanceContext = { - inputs: Record; -}; - -export class InstanceContextStore { - private context: InstanceContext = { - inputs: {}, - }; - private onChangeCallbacks: Set<() => void> = new Set(); - private eventQueue?: UpdateAction[]; - - /** - * Apply update immediately if there are no `runBlocking` calls in progress. - * Otherwise wait for `runBlocking call to finish`. - */ - public dispatchUpdate(update: UpdateAction) { - if (this.eventQueue) { - this.eventQueue.push(update); - } else { - this.applyUpdate(update); - } - } - - /** - * No dispatch events will be processed while `fn` function executes. - * Argument passed to the callback should be used instead of `this.dispatchUpdate` - * to update the store from inside `fn` - */ - public async runBlocking( - fn: (update: (action: UpdateAction) => void) => Promise - ): Promise { - this.eventQueue = []; - try { - return await fn(a => this.applyUpdate(a)); - } finally { - for (const event of this.eventQueue) { - this.applyUpdate(event); - } - this.eventQueue = undefined; - } - } - - private applyUpdate(update: UpdateAction) { - if (update.type === 'add_input') { - this.addInput(update.input); - } else if (update.type === 'update_input') { - this.updateInput(update.input); - } else if (update.type === 'remove_input') { - this.removeInput(update.inputId); - } - } - - private addInput(input: InputStreamInfo) { - if (this.context.inputs[input.inputId]) { - console.warn(`Adding input ${input.inputId}. Input already exists.`); - } - this.context = { - ...this.context, - inputs: { ...this.context.inputs, [input.inputId]: input }, - }; - this.signalUpdate(); - } - - private updateInput(update: InputStreamInfo) { - const oldInput = this.context.inputs[update.inputId]; - if (!oldInput) { - console.warn(`Updating input ${update.inputId}. Input does not exist.`); - return; - } - this.context = { - ...this.context, - inputs: { - ...this.context.inputs, - [update.inputId]: { ...oldInput, ...update }, - }, - }; - this.signalUpdate(); - } - - private removeInput(inputId: string) { - const inputs = { ...this.context.inputs }; - delete inputs[inputId]; - this.context = { ...this.context, inputs }; - this.signalUpdate(); - } - - private signalUpdate() { - for (const cb of this.onChangeCallbacks) { - cb(); - } - } - - // callback for useSyncExternalStore - public getSnapshot = (): InstanceContext => { - return this.context; - }; - - // callback for useSyncExternalStore - public subscribe = (onStoreChange: () => void): (() => void) => { - this.onChangeCallbacks.add(onStoreChange); - return () => { - this.onChangeCallbacks.delete(onStoreChange); - }; - }; -} diff --git a/ts/live-compositor/src/context/internalStreamStore.ts b/ts/live-compositor/src/context/internalStreamStore.ts new file mode 100644 index 000000000..467b2c6f4 --- /dev/null +++ b/ts/live-compositor/src/context/internalStreamStore.ts @@ -0,0 +1,10 @@ +let nextStreamNumber = 1; + +/* + * Generates unique input stream id that can be used in e.g. Mp4 component + */ +export function newInternalStreamId(): number { + const result = nextStreamNumber; + nextStreamNumber += 1; + return result; +} diff --git a/ts/live-compositor/src/context/outputContext.ts b/ts/live-compositor/src/context/outputContext.ts deleted file mode 100644 index 3f188e48d..000000000 --- a/ts/live-compositor/src/context/outputContext.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { AudioInputsConfiguration } from '../types/registerOutput.js'; - -export type ContextAudioOptions = { - volume: number; -}; - -export type AudioConfig = AudioInputConfig[]; - -export type AudioInputConfig = { - inputId: string; - volumeComponents: ContextAudioOptions[]; -}; - -export class OutputContext { - private audioMixerConfig?: AudioConfig; - private onChange: () => void; - - constructor(onChange: () => void, supportsAudio: boolean) { - this.audioMixerConfig = supportsAudio ? [] : undefined; - this.onChange = onChange; - } - - public getAudioConfig(): AudioInputsConfiguration | undefined { - if (!this.audioMixerConfig) { - return undefined; - } - - return { - inputs: this.audioMixerConfig.map(input => ({ - inputId: input.inputId, - volume: Math.min( - input.volumeComponents.reduce((acc, opt) => acc + opt.volume, 0), - 1.0 - ), - })), - }; - } - - public addInputAudioComponent(inputId: string, options: ContextAudioOptions) { - if (!this.audioMixerConfig) { - return; - } - - const inputConfig = this.audioMixerConfig.find(input => input.inputId === inputId); - if (inputConfig) { - inputConfig.volumeComponents = [...inputConfig.volumeComponents, options]; - } else { - this.audioMixerConfig = [ - ...this.audioMixerConfig, - { - inputId, - volumeComponents: [options], - }, - ]; - } - this.onChange(); - } - - public removeInputAudioComponent(inputId: string, options: ContextAudioOptions) { - if (!this.audioMixerConfig) { - return; - } - - const inputConfig = this.audioMixerConfig.find(input => input.inputId === inputId); - if (inputConfig) { - // opt !== options compares objects by reference - inputConfig.volumeComponents = inputConfig.volumeComponents.filter(opt => opt !== options); - this.onChange(); - } - } -} diff --git a/ts/live-compositor/src/context/timeContext.ts b/ts/live-compositor/src/context/timeContext.ts new file mode 100644 index 000000000..1fb893a3a --- /dev/null +++ b/ts/live-compositor/src/context/timeContext.ts @@ -0,0 +1,139 @@ +export interface BlockingTask { + done(): void; +} + +export interface TimeContext { + timestampMs(): number; + + addTimestamp(timestamp: TimestampObject): void; + removeTimestamp(timestamp: TimestampObject): void; + + getSnapshot: () => number; + subscribe: (onStoreChange: () => void) => () => void; +} + +// Wrapped in object, so we can compare it by reference. +type TimestampObject = { timestamp: number }; + +export class OfflineTimeContext { + private timestamps: TimestampObject[]; + private tasks: BlockingTask[]; + private onChange: () => void; + private currentTimestamp: number = 0; + private onChangeCallbacks: Set<() => void> = new Set(); + + constructor(onChange: () => void, onTimeChange: (timestam: number) => void) { + this.onChange = onChange; + this.tasks = []; + this.timestamps = []; + this.onChangeCallbacks.add(() => { + onTimeChange(this.currentTimestamp); + }); + } + + public timestampMs(): number { + return this.currentTimestamp; + } + + public isBlocked(): boolean { + return this.tasks.length !== 0; + } + + public newBlockingTask(): BlockingTask { + const task: BlockingTask = {} as any; + task.done = () => { + this.tasks = this.tasks.filter(t => t !== task); + if (this.tasks.length === 0) { + this.onChange(); + } + }; + this.tasks.push(task); + return task; + } + + public addTimestamp(timestamp: TimestampObject) { + this.timestamps.push(timestamp); + } + + public removeTimestamp(timestamp: TimestampObject) { + this.timestamps = this.timestamps.filter(t => timestamp !== t); + } + + public setNextTimestamp() { + const next = this.timestamps.reduce( + (acc, value) => + value.timestamp < acc.timestamp && value.timestamp > this.currentTimestamp ? value : acc, + { timestamp: Infinity } + ); + this.currentTimestamp = next.timestamp; + for (const cb of this.onChangeCallbacks) { + cb(); + } + } + + // callback for useSyncExternalStore + public getSnapshot = (): number => { + return this.currentTimestamp; + }; + + // callback for useSyncExternalStore + public subscribe = (onStoreChange: () => void): (() => void) => { + this.onChangeCallbacks.add(onStoreChange); + return () => { + this.onChangeCallbacks.delete(onStoreChange); + }; + }; +} + +export class LiveTimeContext { + private startTimestampMs: number = 0; + private timestamps: Array<{ timestamp: TimestampObject; timeout?: number }>; + private onChangeCallbacks: Set<() => void> = new Set(); + + constructor() { + this.timestamps = []; + } + + public timestampMs(): number { + return this.startTimestampMs ? Date.now() - this.startTimestampMs : 0; + } + + public initClock(timestamp: number) { + this.startTimestampMs = timestamp; + } + + public addTimestamp(timestamp: TimestampObject) { + this.timestamps.push({ timestamp, timeout: this.scheduleChangeNotification(timestamp) }); + } + + public removeTimestamp(timestamp: TimestampObject) { + const removed = this.timestamps.filter(t => timestamp === t.timestamp); + this.timestamps = this.timestamps.filter(t => timestamp !== t.timestamp); + removed.forEach(ts => clearTimeout(ts.timeout)); + } + + private scheduleChangeNotification(timestamp: TimestampObject): number | undefined { + const timeLeft = timestamp.timestamp - this.timestampMs(); + if (timeLeft < 0) { + return; + } + return setTimeout(() => { + for (const cb of this.onChangeCallbacks) { + cb(); + } + }, timeLeft + 100); + } + + // callback for useSyncExternalStore + public getSnapshot = (): number => { + return this.timestampMs(); + }; + + // callback for useSyncExternalStore + public subscribe = (onStoreChange: () => void): (() => void) => { + this.onChangeCallbacks.add(onStoreChange); + return () => { + this.onChangeCallbacks.delete(onStoreChange); + }; + }; +} diff --git a/ts/live-compositor/src/hooks.ts b/ts/live-compositor/src/hooks.ts index 49bff0df5..75fea286f 100644 --- a/ts/live-compositor/src/hooks.ts +++ b/ts/live-compositor/src/hooks.ts @@ -1,16 +1,19 @@ -import { useContext, useEffect, useSyncExternalStore } from 'react'; +import { useContext, useEffect, useState, useSyncExternalStore } from 'react'; import type * as Api from './api.js'; +import type { CompositorOutputContext } from './context/index.js'; import { LiveCompositorContext } from './context/index.js'; -import type { InputStreamInfo } from './context/instanceContextStore.js'; +import type { BlockingTask } from './context/timeContext.js'; +import { OfflineTimeContext } from './context/timeContext.js'; +import type { InputStreamInfo } from './context/inputStreamStore.js'; -export function useInputStreams(): Record { +export function useInputStreams(): Record> { const ctx = useContext(LiveCompositorContext); const instanceCtx = useSyncExternalStore( - ctx.instanceStore.subscribe, - ctx.instanceStore.getSnapshot + ctx.globalInputStreamStore.subscribe, + ctx.globalInputStreamStore.getSnapshot ); - return instanceCtx.inputs; + return instanceCtx; } export type AudioOptions = { @@ -26,9 +29,85 @@ export function useAudioInput(inputId: Api.InputId, audioOptions: AudioOptions) useEffect(() => { const options = { ...audioOptions }; - ctx.outputCtx.addInputAudioComponent(inputId, options); + ctx.audioContext.addInputAudioComponent({ type: 'global', id: inputId }, options); return () => { - ctx.outputCtx.removeInputAudioComponent(inputId, options); + ctx.audioContext.removeInputAudioComponent({ type: 'global', id: inputId }, options); }; }, [audioOptions]); } + +/** + * Returns current timestamp relative to `LiveCompositor.start()`. + * + * Not recommended for live processing. It triggers re-renders only for specific timestamps + * that are registered with `useAfterTimestamp` hook(that includes components like Slide/Show). + */ +export function useCurrentTimestamp(): number { + const ctx = useContext(LiveCompositorContext); + const timeContext = ctx.timeContext; + useSyncExternalStore(timeContext.subscribe, timeContext.getSnapshot); + // Value from useSyncExternalStore is the same as TimeContext.timestampMs for + // offline processing, but for live `timestampMs` should be up to date. + return timeContext.timestampMs(); +} + +/** + * Hook that allows you to trigger updates after specific timestamp. Primary useful for + * offline processing. + */ +export function useAfterTimestamp(timestamp: number): boolean { + const ctx = useContext(LiveCompositorContext); + const currentTimestamp = useCurrentTimestamp(); + + useEffect(() => { + if (timestamp === Infinity) { + return; + } + const tsObject = { timestamp }; + ctx.timeContext.addTimestamp(tsObject); + return () => { + ctx.timeContext.removeTimestamp(tsObject); + }; + }, [timestamp]); + + return currentTimestamp >= timestamp; +} + +/** + * Create task that will stop rendering when compositor runs in offline mode. + * + * `task.done()` needs to be called when async action is finished, otherwise rendering will block indefinitely. + */ +export function newBlockingTask(ctx: CompositorOutputContext): BlockingTask { + if (ctx.timeContext instanceof OfflineTimeContext) { + return ctx.timeContext.newBlockingTask(); + } else { + return { done: () => null }; + } +} + +/** + * Run async function and return its result after Promise resolves. + * + * For offline processing it additionally ensures that rendering for that + * timestamp will block until all blocking tasks are done. + */ +export function useBlockingTask(fn: () => Promise): T | undefined { + const ctx = useContext(LiveCompositorContext); + const [result, setResult] = useState(undefined); + useEffect(() => { + const task = newBlockingTask(ctx); + void (async () => { + try { + setResult(await fn()); + } finally { + task.done(); + } + })(); + return () => { + task.done(); + }; + }, []); + + return result; +} diff --git a/ts/live-compositor/src/index.ts b/ts/live-compositor/src/index.ts index 73df9f7b1..4b7cf1d6e 100644 --- a/ts/live-compositor/src/index.ts +++ b/ts/live-compositor/src/index.ts @@ -7,8 +7,16 @@ import WebView, { WebViewProps } from './components/WebView.js'; import Shader, { ShaderParam, ShaderParamStructField, ShaderProps } from './components/Shader.js'; import Tiles, { TilesProps } from './components/Tiles.js'; import { EasingFunction, Transition } from './components/common.js'; -import { useAudioInput, useInputStreams } from './hooks.js'; -import { CompositorEvent, CompositorEventType } from './types/events.js'; +import { + useAudioInput, + useInputStreams, + useAfterTimestamp, + useBlockingTask, + useCurrentTimestamp, +} from './hooks.js'; +import Show, { ShowProps } from './components/Show.js'; +import { SlideShow, Slide, SlideProps, SlideShowProps } from './components/SlideShow.js'; +import Mp4, { Mp4Props } from './components/Mp4.js'; export { RegisterRtpInput, RegisterMp4Input } from './types/registerInput.js'; export { @@ -40,10 +48,16 @@ export { ShaderProps, Tiles, TilesProps, + Show, + ShowProps, + Slide, + SlideProps, + SlideShow, + SlideShowProps, + Mp4, + Mp4Props, }; -export { CompositorEvent, CompositorEventType }; - -export { useInputStreams, useAudioInput }; +export { useInputStreams, useAudioInput, useBlockingTask, useAfterTimestamp, useCurrentTimestamp }; export { ShaderParam, ShaderParamStructField, EasingFunction, Transition }; diff --git a/ts/live-compositor/src/internal.ts b/ts/live-compositor/src/internal.ts index 3edf6c708..54402154f 100644 --- a/ts/live-compositor/src/internal.ts +++ b/ts/live-compositor/src/internal.ts @@ -1,4 +1,18 @@ // Internal logic used by `@live-compositor/core`, do not use directly -export { InstanceContextStore, OutputContext, LiveCompositorContext } from './context/index.js'; +export { LiveCompositorContext, CompositorOutputContext } from './context/index.js'; +export { OfflineTimeContext, LiveTimeContext, TimeContext } from './context/timeContext.js'; +export { AudioConfig } from './context/audioOutputContext.js'; +export { AudioContext } from './context/audioOutputContext.js'; +export { + InputStreamStore, + LiveInputStreamStore, + OfflineInputStreamStore, +} from './context/inputStreamStore.js'; export { SceneBuilder, SceneComponent } from './component.js'; +export { CompositorEvent, CompositorEventType } from './types/events.js'; +export { InputRef, inputRefIntoRawId, parseInputRef } from './types/inputRef.js'; +export { + ChildrenLifetimeContext, + ChildrenLifetimeContextType, +} from './context/childrenLifetimeContext.js'; diff --git a/ts/live-compositor/src/types/events.ts b/ts/live-compositor/src/types/events.ts index 16bc777c8..7f0ce7b0e 100644 --- a/ts/live-compositor/src/types/events.ts +++ b/ts/live-compositor/src/types/events.ts @@ -1,3 +1,5 @@ +import type { InputRef } from './inputRef.js'; + export enum CompositorEventType { AUDIO_INPUT_DELIVERED = 'AUDIO_INPUT_DELIVERED', VIDEO_INPUT_DELIVERED = 'VIDEO_INPUT_DELIVERED', @@ -9,10 +11,10 @@ export enum CompositorEventType { } export type CompositorEvent = - | { type: CompositorEventType.AUDIO_INPUT_DELIVERED; inputId: string } - | { type: CompositorEventType.VIDEO_INPUT_DELIVERED; inputId: string } - | { type: CompositorEventType.AUDIO_INPUT_PLAYING; inputId: string } - | { type: CompositorEventType.VIDEO_INPUT_PLAYING; inputId: string } - | { type: CompositorEventType.AUDIO_INPUT_EOS; inputId: string } - | { type: CompositorEventType.VIDEO_INPUT_EOS; inputId: string } + | { type: CompositorEventType.AUDIO_INPUT_DELIVERED; inputRef: InputRef } + | { type: CompositorEventType.VIDEO_INPUT_DELIVERED; inputRef: InputRef } + | { type: CompositorEventType.AUDIO_INPUT_PLAYING; inputRef: InputRef } + | { type: CompositorEventType.VIDEO_INPUT_PLAYING; inputRef: InputRef } + | { type: CompositorEventType.AUDIO_INPUT_EOS; inputRef: InputRef } + | { type: CompositorEventType.VIDEO_INPUT_EOS; inputRef: InputRef } | { type: CompositorEventType.OUTPUT_DONE; outputId: string }; diff --git a/ts/live-compositor/src/types/inputRef.ts b/ts/live-compositor/src/types/inputRef.ts new file mode 100644 index 000000000..0ad143279 --- /dev/null +++ b/ts/live-compositor/src/types/inputRef.ts @@ -0,0 +1,55 @@ +/** + * Represents ID of an input, it can mean either: + * - Input registered with `registerInput` method. + * - Input that was registered internally by components like . + */ +export type InputRef = + | { + // Maps to "global:{id}" in HTTP API + type: 'global'; + id: string; + } + | { + // Maps to "output-local:{id}:{outputId}" in HTTP API + type: 'output-local'; + outputId: string; + id: number; + }; + +export function areInputRefsEqual(ref1: InputRef, ref2: InputRef): boolean { + const sameType = ref1.type === ref2.type; + const sameId = ref1.id === ref2.id; + if (ref1.type === 'output-local' && ref2.type === 'output-local') { + return sameId && sameType && ref1.outputId === ref2.outputId; + } else { + return sameId && sameType; + } +} + +export function inputRefIntoRawId(inputRef: InputRef): string { + if (inputRef.type == 'global') { + return `global:${inputRef.id}`; + } else { + return `output-local:${inputRef.id}:${inputRef.outputId}`; + } +} + +export function parseInputRef(rawId: string): InputRef { + const split = rawId.split(':'); + if (split.length < 2) { + throw new Error(`Invalid input ID. (${rawId})`); + } else if (split[0] === 'global') { + return { + type: 'global', + id: split.slice(1).join(), + }; + } else if (split[0] === 'output-local') { + return { + type: 'output-local', + id: Number(split[1]), + outputId: split.slice(2).join(), + }; + } else { + throw new Error(`Unknown input type (${split[0]}).`); + } +} diff --git a/ts/live-compositor/src/types/registerOutput.ts b/ts/live-compositor/src/types/registerOutput.ts index 977d9b77d..4859cdcd7 100644 --- a/ts/live-compositor/src/types/registerOutput.ts +++ b/ts/live-compositor/src/types/registerOutput.ts @@ -1,4 +1,3 @@ -import type React from 'react'; import type * as Api from '../api.js'; export type RegisterRtpOutput = { @@ -52,8 +51,6 @@ export type RtpVideoOptions = { * Video encoder options. */ encoder: RtpVideoEncoderOptions; - - root: React.ReactElement; }; export type Mp4VideoOptions = { @@ -69,8 +66,6 @@ export type Mp4VideoOptions = { * Video encoder options. */ encoder: Mp4VideoEncoderOptions; - - root: React.ReactElement; }; export type OutputCanvasVideoOptions = { @@ -82,7 +77,6 @@ export type OutputCanvasVideoOptions = { * HTMLCanvasElement */ canvas: any; - root: React.ReactElement; }; export type RtpVideoEncoderOptions = { @@ -122,10 +116,6 @@ export type RtpAudioOptions = { * Audio encoder options. */ encoder: RtpAudioEncoderOptions; - /** - * Initial audio mixer configuration for output. - */ - initial?: AudioInputsConfiguration; }; export interface Mp4AudioOptions { @@ -141,10 +131,6 @@ export interface Mp4AudioOptions { * Audio encoder options. */ encoder: Mp4AudioEncoderOptions; - /** - * Initial audio mixer configuration for output. - */ - initial?: AudioInputsConfiguration; } export type RtpAudioEncoderOptions = { @@ -186,15 +172,3 @@ export type OutputEndCondition = */ allInputs: boolean; }; - -export interface AudioInputsConfiguration { - inputs: InputAudio[]; -} - -export interface InputAudio { - inputId: Api.InputId; - /** - * (**default=`1.0`**) float in `[0, 1]` range representing input volume - */ - volume?: number | null; -} diff --git a/ts/live-compositor/tsconfig.cjs.json b/ts/live-compositor/tsconfig.cjs.json index 6812a1212..fba5b309d 100644 --- a/ts/live-compositor/tsconfig.cjs.json +++ b/ts/live-compositor/tsconfig.cjs.json @@ -4,6 +4,6 @@ "outDir": "cjs", "module": "commonjs", "moduleResolution": "node", - "target": "es2015" + "target": "es2019" } } diff --git a/ts/live-compositor/tsconfig.json b/ts/live-compositor/tsconfig.json index c86bcad63..1cc6f86ee 100644 --- a/ts/live-compositor/tsconfig.json +++ b/ts/live-compositor/tsconfig.json @@ -2,5 +2,6 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "esm" - } + }, + "include": ["src"] } diff --git a/ts/package.json b/ts/package.json index f4aeb0e78..aa76dedae 100644 --- a/ts/package.json +++ b/ts/package.json @@ -29,6 +29,7 @@ "globals": "^15.9.0", "json-schema-to-typescript": "^15.0.1", "prettier": "^3.3.3", + "rimraf": "^6.0.1", "typescript": "5.5.3" }, "overrides": { diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 1faa9833e..1d5803a89 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: prettier: specifier: ^3.3.3 version: 3.3.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 typescript: specifier: 5.5.3 version: 5.5.3 @@ -1558,6 +1561,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -1746,6 +1754,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1815,6 +1827,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1856,6 +1872,10 @@ packages: engines: {node: '>=4'} hasBin: true + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2009,6 +2029,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.10: resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} @@ -2130,6 +2154,11 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rollup-plugin-copy@3.5.0: resolution: {integrity: sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA==} engines: {node: '>=8.3'} @@ -3611,7 +3640,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 9.15.0 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-plugin-import@2.31.0)(eslint@9.15.0))(eslint@9.15.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.15.0) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -3624,7 +3653,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-plugin-import@2.31.0)(eslint@9.15.0))(eslint@9.15.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.15.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -3646,7 +3675,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.15.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-plugin-import@2.31.0)(eslint@9.15.0))(eslint@9.15.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.15.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.15.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -3935,6 +3964,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.0.0: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4119,6 +4157,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -4186,6 +4228,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4217,6 +4261,10 @@ snapshots: mime@1.6.0: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -4350,6 +4398,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.2 + minipass: 7.1.2 + path-to-regexp@0.1.10: {} path-type@4.0.0: {} @@ -4454,6 +4507,11 @@ snapshots: dependencies: glob: 10.4.5 + rimraf@6.0.1: + dependencies: + glob: 11.0.0 + package-json-from-dist: 1.0.1 + rollup-plugin-copy@3.5.0: dependencies: '@types/fs-extra': 8.1.5