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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ Every panel is a tab you can drag, split, or stack.

| Panel | Use it to |
| --- | --- |
| Preview, Program | See the scene you are editing and the one that is live, and drag items in the preview to place them. **Cut** puts the preview on program at once; **Fade** blends over the fade time set beside it. **Interact** passes the mouse over the preview to the browser source under it instead, and the keyboard to the one last clicked. |
| Scenes | Pick the scene in preview, and add, rename (double-click), reorder, or remove one. A scene on program, or still fading out, cannot be removed, even by an undo, until program cuts away from it. |
| Preview, Program | See the scene you are editing and the one that is live, and drag items in the preview to place them. **Cut** puts the preview on program at once; **Transition** takes it there by the transition picked beside it, over the time set there (see [Transitions](#transitions)). **Interact** passes the mouse over the preview to the browser source under it instead, and the keyboard to the one last clicked. |
| Scenes | Pick the scene in preview, and add, rename (double-click), reorder, or remove one. A scene on program, or still leaving it in a transition, cannot be removed, even by an undo, until program cuts away from it. |
| History | See every edit, and undo or redo back to any of them. |
| Items | Place, show, hide, and order what the preview scene or the top layer draws, back to front. Select an item to filter or copy it. |
| Sources | Add a source by hand, see whether each and its own track are live and where it is shown, clone one, and remove one. Select a source to filter it. A camera or broadcast without a new picture, or a microphone or system audio without new sound, for a second reads as stalled. A browser source shows whether its page is copied on the GPU or, as a fallback, uploaded from memory (CPU), in place of a track, and **Reload** restarts its page. |
Expand All @@ -105,8 +105,22 @@ Every panel is a tab you can drag, split, or stack.

A new source, from the Sources or Devices panel, is placed over whatever the
Items panel shows. The **top layer** is drawn over every scene, in preview and
on program, and stays on through a cut or fade: a downstream keyer for a logo
or a lower third.
on program, and stays on through a cut or transition: a downstream keyer for a
logo or a lower third.

### Transitions

| Transition | What it does |
| --- | --- |
| Fade | The incoming scene fades in over the outgoing one, at an even pace. |
| Slide | The incoming scene pushes the outgoing one off the canvas, both moving left, right, up, or down. |
| Wipe | An edge sweeps across the canvas left, right, up, or down, uncovering the incoming scene behind it. |
| Move | Each item showing a source both scenes place glides from its place in the outgoing scene to its place in the incoming one, resizing, recropping, and mixing its filters' settings on the way. If its blend mode or kinds of filter differ, it changes look halfway. The rest of the outgoing scene fades out and the rest of the incoming scene fades in. Items stack as the outgoing scene has them until halfway, then as the incoming one does. A source placed more than once pairs up in order, back to front. |

All but a fade ease in and out. A transition is drawn on the program clock, so
the stream and a recording show the same frames of it, and a second
**Transition** while one runs starts from the scene it was heading to. A
transition set to 0 ms is a cut.

In the preview, click an item of whatever the Items panel shows to select it,
drag it to move it, and drag its edges or corners to resize it. A drag snaps
Expand Down Expand Up @@ -284,7 +298,9 @@ height = 1080
framerate = 30

[transition]
fade = "300ms"
kind = "slide" # fade, slide, wipe, or move
duration = "300ms"
direction = "left" # slide and wipe: left, right, up, or down

[sources.camera]
kind = "camera"
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ repository.
- [Broadcast sources play in sync](/quest/m0/broadcast-sync.md) - subscribed broadcasts and guests play audio and video on the publisher's timestamps, with `moq play`'s delay
- [Wayland display and window sources](/quest/m0/wayland-sources.md) - portal-picked sources that reopen without the picker
- [Live encode control](/quest/m0/encode-control.md) - simulcast rungs that follow the bandwidth grant, live bitrate and resolution, keyframes on request
- [Animation and transitions](/quest/m0/animation.md) - move, slide, wipe, and stinger transitions, and keyframed item properties
- [Keyframes and stingers](/quest/m0/animation.md) - keyframed item properties with easing, and stinger transitions
- [ML video effects](/quest/m0/effects.md) - background removal and other ML effects, baked into their source's track
- [Destinations](/quest/m0/destinations.md) - RTMP, RTMPS, and WHIP push with per-destination codecs and shared encoders, on a UI-free egress library a hosted service can reuse
- [Multiple canvases](/quest/m0/multi-canvas.md) - landscape and portrait canvases composite at once from shared sources
Expand Down
44 changes: 29 additions & 15 deletions quest/m0/animation.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
# [M] Animation and transitions
# [M] Keyframes and stingers

## Goal

Scene changes in `moq-studio` use transitions beyond a cut and fade: move
(items glide between their positions in the two scenes), slide, wipe, and
stinger (a video clip with a cut point). Any item property can be keyframed
with easing, so an item can fly in or a camera can push in.
Any item property in `moq-studio` can be keyframed with easing, so an item
can fly in or a camera can push in, and a scene change can run a stinger: a
video clip played over the program that switches scenes at its cut point.

## Plan

Move is OBS's most-installed plugin (Move Transition), and stingers are
standard in broadcast. Animate in moq-scene on the media clock, so the program, a recording, and a
replay match. Carrying transitions and keyframes in the layout track, for
viewers to compose, waits for the m1
[composition epic](/quest/m1/compose/README.md) (changed 2026-11-04,
maintainer: the program comes first).
Overlays animate through their own CSS. No Lottie runtime for now (decided
2026-10-26): the open ones either lack text (velato) or add a C++ build
(ThorVG). A stinger clip is a media source like any other. Transitions and
keyframes change through moq-scene's `Edit`, so they undo like any edit.
Fade, slide, wipe, and move transitions are drawn by moq-scene's compositor
on the program clock
([moq-dev/studio#33](https://github.com/moq-dev/studio/pull/33)). Animate in
moq-scene on the media clock, so the program, a recording, and a replay match.
Carrying transitions and keyframes in the layout track, for viewers to
compose, waits for the m1 [composition epic](/quest/m1/compose/README.md)
(changed 2026-11-04, maintainer: the program comes first). Overlays animate
through their own CSS. No Lottie runtime for now (decided 2026-10-26): the
open ones either lack text (velato) or add a C++ build (ThorVG). Keyframes and
stinger settings change through moq-scene's `Edit`, so they undo like any edit.

A stinger clip is a media source like any other, but no source plays a media
file yet, and a stinger must start its clip as the transition starts or its
cut point lands at random. It waits on a media-file source that plays from
the start on demand, which no quest plans yet.

Open questions for the maintainer:

- What starts an item's keyframes: its scene going on program (and into
preview, for the preview), a loop on the program clock, or a trigger such
as a hotkey. Recommended: the time since its scene went on program, playing
once and holding the last keyframe.
- How a keyframed item edits: a drag in the preview sets the keyframe at the
preview's time, or the item's resting place, from which keyframes are
offsets.

## Related

Expand Down
225 changes: 214 additions & 11 deletions rs/moq-scene/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::Error;
pub struct Collection {
/// The output every scene is drawn onto.
pub canvas: Canvas,
/// How a fade from preview to program behaves.
/// How the preview is taken to program, short of a cut.
#[serde(default)]
pub transition: Transition,
/// Every source, by its unique name. Scenes refer to sources by that name,
Expand Down Expand Up @@ -329,23 +329,108 @@ impl Default for Canvas {
}
}

/// How the program changes when the preview is faded in.
/// How the program changes when the preview is taken to it, short of a cut.
///
/// A fade runs at an even pace; the others ease in and out, so motion starts
/// and lands gently. One that takes no time is a cut.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transition {
/// How long a fade takes.
#[serde(with = "humantime_serde")]
pub fade: Duration,
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Transition {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// The incoming scene fades in over the outgoing one.
Fade {
/// How long it takes.
#[serde(with = "humantime_serde")]
duration: Duration,
},
/// The incoming scene pushes the outgoing one off the canvas, both moving
/// in `direction`.
Slide {
/// How long it takes.
#[serde(with = "humantime_serde")]
duration: Duration,
/// Which way the scenes move.
direction: Direction,
},
/// An edge sweeps across the canvas in `direction`, uncovering the
/// incoming scene behind it.
Wipe {
/// How long it takes.
#[serde(with = "humantime_serde")]
duration: Duration,
/// Which way the edge moves.
direction: Direction,
},
/// Each item showing a source both scenes place glides from where the
/// outgoing scene has it to where the incoming one does, resizing and
/// recropping on the way; the other items fade out and in.
Move {
/// How long it takes.
#[serde(with = "humantime_serde")]
duration: Duration,
},
}

impl Transition {
/// How long it takes.
pub fn duration(&self) -> Duration {
match *self {
Self::Fade { duration }
| Self::Slide { duration, .. }
| Self::Wipe { duration, .. }
| Self::Move { duration } => duration,
}
}

/// How long it takes, to change.
pub fn duration_mut(&mut self) -> &mut Duration {
match self {
Self::Fade { duration }
| Self::Slide { duration, .. }
| Self::Wipe { duration, .. }
| Self::Move { duration } => duration,
}
}

/// How far along it looks once `linear` of its time has passed, both from
/// 0 to 1: as is for a fade, and eased in and out otherwise.
pub(crate) fn progress(&self, linear: f32) -> f32 {
let linear = linear.clamp(0.0, 1.0);
match self {
Self::Fade { .. } => linear,
// The cubic ease in and out.
_ if linear < 0.5 => 4.0 * linear.powi(3),
_ => 1.0 - (2.0 - 2.0 * linear).powi(3) / 2.0,
}
}
}

impl Default for Transition {
fn default() -> Self {
Self {
fade: Duration::from_millis(300),
Self::Fade {
duration: Duration::from_millis(300),
}
}
}

/// Which way a [`Transition`] moves across the canvas.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
/// Toward the left edge.
Left,
/// Toward the right edge.
Right,
/// Toward the top edge.
Up,
/// Toward the bottom edge.
Down,
}

impl Direction {
/// Every direction.
pub const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Up, Self::Down];
}

/// One arrangement of sources on the canvas.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -566,6 +651,84 @@ impl Filter {
}
}

/// `progress` of the way from this filter's settings to `other`'s, or
/// `None` if they are of different kinds or read different sources or
/// mask modes. A mix of valid settings is valid.
pub(crate) fn toward(&self, other: &Self, progress: f32) -> Option<Self> {
let mix = |from: f32, to: f32| mix(from, to, progress);
let color = |from: Rgba, to: Rgba| {
Rgba(std::array::from_fn(|channel| {
mix(f32::from(from.0[channel]), f32::from(to.0[channel])).round() as u8
}))
};
Some(match (self, other) {
(Self::Opacity { amount: a }, Self::Opacity { amount: b }) => Self::Opacity { amount: mix(*a, *b) },
(Self::Brightness { amount: a }, Self::Brightness { amount: b }) => {
Self::Brightness { amount: mix(*a, *b) }
}
(Self::Contrast { amount: a }, Self::Contrast { amount: b }) => Self::Contrast { amount: mix(*a, *b) },
(Self::Saturate { amount: a }, Self::Saturate { amount: b }) => Self::Saturate { amount: mix(*a, *b) },
(Self::Grayscale { amount: a }, Self::Grayscale { amount: b }) => Self::Grayscale { amount: mix(*a, *b) },
(Self::Sepia { amount: a }, Self::Sepia { amount: b }) => Self::Sepia { amount: mix(*a, *b) },
(Self::Invert { amount: a }, Self::Invert { amount: b }) => Self::Invert { amount: mix(*a, *b) },
(Self::HueRotate { degrees: a }, Self::HueRotate { degrees: b }) => {
Self::HueRotate { degrees: mix(*a, *b) }
}
(Self::Blur { radius: a }, Self::Blur { radius: b }) => Self::Blur { radius: mix(*a, *b) },
(
Self::DropShadow { x, y, blur, color: c },
Self::DropShadow {
x: x2,
y: y2,
blur: blur2,
color: c2,
},
) => Self::DropShadow {
x: mix(*x, *x2),
y: mix(*y, *y2),
blur: mix(*blur, *blur2),
color: color(*c, *c2),
},
(
Self::ChromaKey {
color: c,
similarity,
smoothness,
spill,
},
Self::ChromaKey {
color: c2,
similarity: similarity2,
smoothness: smoothness2,
spill: spill2,
},
) => Self::ChromaKey {
color: color(*c, *c2),
similarity: mix(*similarity, *similarity2),
smoothness: mix(*smoothness, *smoothness2),
spill: mix(*spill, *spill2),
},
(
Self::LumaKey { min, max, smoothness },
Self::LumaKey {
min: min2,
max: max2,
smoothness: smoothness2,
},
) => Self::LumaKey {
min: mix(*min, *min2),
max: mix(*max, *max2),
smoothness: mix(*smoothness, *smoothness2),
},
(Self::Lut { source, amount: a }, Self::Lut { source: to, amount: b }) if source == to => Self::Lut {
source: source.clone(),
amount: mix(*a, *b),
},
(Self::Mask { .. }, Self::Mask { .. }) if self == other => self.clone(),
_ => return None,
})
}

/// The source this filter reads, to rename it.
pub(crate) fn source_mut(&mut self) -> Option<&mut String> {
match self {
Expand Down Expand Up @@ -731,6 +894,15 @@ impl Crop {
}
}

/// `progress` of the way from `from` to `to`, finite whenever both are, since
/// it never leaves the range between them.
pub(crate) fn mix(from: f32, to: f32, progress: f32) -> f32 {
// In f64, where neither product can overflow, and each term's weight
// rounds no larger than it is.
let progress = f64::from(progress);
(f64::from(from) * (1.0 - progress) + f64::from(to) * progress) as f32
}

pub(crate) fn validate_filters(filters: &[Filter], on: &str) -> Result<(), Error> {
for filter in filters {
filter
Expand Down Expand Up @@ -1085,7 +1257,9 @@ height = 720
framerate = 60

[transition]
fade = "500ms"
kind = "slide"
duration = "500ms"
direction = "up"

[sources.cam]
kind = "camera"
Expand Down Expand Up @@ -1132,7 +1306,13 @@ rect = { x = 0, y = 680, width = 1280, height = 40 }
"##;
let collection = Collection::from_toml(text).unwrap();
assert_eq!(collection.canvas.framerate, 60);
assert_eq!(collection.transition.fade, Duration::from_millis(500));
assert_eq!(
collection.transition,
Transition::Slide {
duration: Duration::from_millis(500),
direction: Direction::Up
}
);
assert_eq!(collection.sources["cam"], Source::Camera { id: None });
assert_eq!(
collection.sources["bg"],
Expand Down Expand Up @@ -1342,6 +1522,29 @@ rect = { x = 0, y = 680, width = 1280, height = 40 }
}
}

/// Mixing two valid settings is valid, even from one end of the floats to
/// the other, and a mix of different kinds is refused.
#[test]
fn filters_mix_within_their_settings() {
let shadow = |x: f32| Filter::DropShadow {
x,
y: -x,
blur: 0.0,
color: Rgba([0, 0, 0, 255]),
};
for progress in [0.0, 0.25, 0.5, 1.0] {
let mixed = shadow(-f32::MAX).toward(&shadow(f32::MAX), progress).unwrap();
validate_filters(&[mixed], "mixed").unwrap();
let hue = |degrees| Filter::HueRotate { degrees };
let mixed = hue(f32::MAX).toward(&hue(-f32::MAX), progress).unwrap();
validate_filters(&[mixed], "mixed").unwrap();
}
assert_eq!(mix(-f32::MAX, f32::MAX, 0.5), 0.0);
assert_eq!(mix(f32::MAX, f32::MAX, 0.3), f32::MAX);
let invert = Filter::Invert { amount: 1.0 };
assert!(invert.toward(&Filter::Sepia { amount: 1.0 }, 0.5).is_none());
}

#[test]
fn refuses_a_crop_that_leaves_nothing() {
let crops = [
Expand Down
Loading