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
61 changes: 50 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Every panel is a tab you can drag, split, or stack.
| 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. |
| 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. A media file reads as ended once it plays to its end without looping, **Play** plays it from its start, and **Reload** reads the file again. |
| Devices | List this machine's cameras, displays, windows, and audio inputs, and add one as a source. |
| Guests | Invite a guest, show their join link as a QR code, see who joined, and take a guest out of every scene. |
| Filters | Crop the selected source or item, add, order, and set its filters, set an item's blend mode, and copy or paste filters. |
Expand Down Expand Up @@ -264,6 +264,43 @@ costs nothing until it is shown again, where its animation has carried on.
Overlays publish no track of their own, so viewers see them only in the
program.

### Media files

A `media` source plays a video or audio file, such as an intro clip or a music
bed, on the program clock, and its sound joins the mixes like any other
source's. It reads fragmented MP4, MKV or WebM, MPEG-TS, and FLV, as moq's
importers do, and fails to start on a codec this machine cannot decode. A
regular MP4 fails to start with a remux to fragmented MP4 that makes it readable:
`ffmpeg -i in.mp4 -c copy -movflags frag_keyframe+empty_moov out.mp4`. The
whole file is read into memory, compressed, when the source starts, so it
plays from its start at once and loops without reading the disk again; keep it
to clips and music, not hours of video.

```toml
[sources.intro]
kind = "media"
path = "intro.mkv" # relative to the scene file, like an image's
restart = true # play from the start each time it goes on program

[sources.music]
kind = "media"
path = "music.webm"
loop = true # start over at the end
```

Without `loop`, it plays once, then holds its last picture, silent, and reads
as ended in Sources and, while the program shows or plays it, in Health.
**Play** in Sources plays it from its start again, as does going on program
with `restart`, in a scene on air or the top layer, when it was not on program
before. Either clears its picture at once, so the end of the last play never
shows on program. A file that cannot be read or decoded fails, with the reason.
Its pictures and its sound each have a track of their own (see [Source
tracks](#source-tracks)), its pictures on one timeline across plays. Each is
used apart, as a camera's and a microphone's are: the pictures while a scene on
air shows it, the sound while the mix on program plays it, and either while
read. So a music bed that no scene shows encodes its sound alone. A file with
no sound has no strip in the Audio panel.

## Guests

Guests and phones join a room the studio owns on its relay, from a web page,
Expand Down Expand Up @@ -467,7 +504,8 @@ rect = { x = 1600, y = 40, width = 280, height = 120 }

Source kinds are `camera`, `display`, and `microphone` (each with an optional
`id`, the default device without one), `window` (with an `id`), `system_audio`
(macOS), `image` (PNG or JPEG), `color` (`#rrggbb` or `#rrggbbaa`),
(macOS), `image` (PNG or JPEG), `media` (a video or audio file; see
[Media files](#media-files)), `color` (`#rrggbb` or `#rrggbbaa`),
`broadcast`, `browser` (a `url`, and a `width` and `height` up to 4096),
`overlay` (its `html`, and a `width` and `height` up to 4096), `clone` (with the `source` it shows), `lut` (a 3D Adobe or Resolve `.cube`
file, which no item places, but which grades what a `lut` filter names it in),
Expand Down Expand Up @@ -584,9 +622,9 @@ stream.

### Source tracks

Each camera, display, window, image, color, microphone, and system audio also
has a track of its own, so the studio can replay or record it on its own. These
tracks stay in the studio: the program is all it publishes to a relay. They are listed in a local
Each camera, display, window, image, media file, color, microphone, and system
audio also has a track of its own, so the studio can replay or record it on its
own. These tracks stay in the studio: the program is all it publishes to a relay. They are listed in a local
catalog under the `dev.moq.studio` section's `sources`, by source name, each
as a hang catalog of its own on the program's clock.

Expand All @@ -602,8 +640,8 @@ plain capture, before any filter, and a clone has none, since it shows its
source's capture. A browser source or an overlay has no track of its own, and a
broadcast source is already its publisher's.

A microphone or system audio encodes its track as Opus, from the same device
open the mixes hear, only while it is used: while the mix on program plays it
A microphone, system audio, or media file's sound encodes its track as Opus,
from the same sound the mixes hear, only while it is used: while the mix on program plays it
(unmuted and not left out of it), so a replay of it has its recent past, or
while a recording reads its track. The track carries the sound as captured,
before its gain, mute, and delay, which stay the mixes'. A buffer the capture
Expand All @@ -620,7 +658,7 @@ encode engine for them all. The Output panel counts the sessions open:
| The program's canvas size, live | 1, or none without `--connect` |
| Its half size | 1 while someone watches it |
| The recording's own canvas size | 1 while recording |
| Each captured source | 1 while on program or recorded |
| Each captured source or media file | 1 while on program or recorded |

So a live scene showing a camera, a display, and a window holds 4 sessions, 5
with the half size watched, and 6 while recording too. When the program
Expand Down Expand Up @@ -702,8 +740,8 @@ it is written, shortly after the click.

Nothing records in the background for this. The program's tracks keep their
last 30 seconds for late viewers anyway, and each source's track while it is on
program (shown in a scene on air, or for a microphone or system audio, played by
the mix on program) or recorded, and a replay copies from those, so a replay
program (a picture shown in a scene on air, or a sound played by the mix on
program) or recorded, and a replay copies from those, so a replay
goes back at most that far. A replay of the program needs `--connect`; a local
source keeps its track without a relay. With `--on-demand`, the program has
no past from before someone watched it, and a source has none from before it
Expand All @@ -723,7 +761,8 @@ with its cause in plain words: a relay is unreachable, the uplink is
congested or losing packets, the relay is far away, the encoder is behind or
stuck, a source stalled (a camera or broadcast the program draws, or a
microphone or system audio the mix on program plays: unmuted and not left
out of it), or viewers are late, stalling, or cannot
out of it), a source the program shows or plays failed or, as a media file
that does not loop, played to its end, or viewers are late, stalling, or cannot
decode a track. A relay lost while the other serves is
degraded, since viewers have nothing left to fall back to; with none serving
it is unhealthy. Below that it shows:
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ repository.
- [Studio on macOS with capture granted](/quest/m0/app-macos.md) - camera, screen, and audio capture run on a Mac, not only Linux
- [Studio on Windows](/quest/m0/app-windows.md) - the studio runs on Windows, not only Linux
- [Wayland display and window sources](/quest/m0/wayland-sources.md) - portal-picked sources that reopen without the picker
- [Media file source](/quest/m0/media-file.md) - a local video or audio file plays as a source, looping or from the start on demand
- [Stinger transitions](/quest/m0/stinger.md) - a clip played over the program that switches scenes at its cut point
- [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
Expand Down
29 changes: 0 additions & 29 deletions quest/m0/media-file.md

This file was deleted.

9 changes: 3 additions & 6 deletions quest/m0/stinger.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ Split from the keyframes quest on 2026-11-04 (maintainer). A
stinger is a transition kind beside fade, slide, wipe, and move
([moq-dev/studio#33](https://github.com/moq-dev/studio/pull/33)), drawn on the
program clock, whose clip is a media file source. It must start its clip as
the transition starts, or its cut point lands at random. Its settings (clip
and cut point) change through `Edit`, so they undo.

## Required

- [Media file source](/quest/m0/media-file.md) - a clip that starts on demand
the transition starts, or its cut point lands at random: `Feed::play` in
`rs/moq-scene/src/source.rs` starts a media source from its start at once.
Its settings (clip and cut point) change through `Edit`, so they undo.
4 changes: 4 additions & 0 deletions quest/m0/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ same PR as this page, and the page follows each pin bump. The page was
planned for moq's demo/web; it now ships from this repository, and where it
is hosted is settled in this quest.

Decided 2026-11-04 (maintainer): a page has no local paths, so an image or
media source's file comes from a browser file picker for the session, and
resolving the scene file's paths in the browser is settled here.

## Required

- [moq-video's browser backends are pinned](/quest/m0/pin-browser-video.md) - the video slice's capture, codecs, and GPU import
Expand Down
46 changes: 41 additions & 5 deletions rs/moq-scene/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,20 @@ pub enum Source {
/// The file, relative to the scene file's directory unless absolute.
path: PathBuf,
},
/// A video or audio file, such as an intro clip or a music bed, played on
/// the program clock from its start, and again whenever
/// [`Feed::play`](crate::source::Feed::play) asks.
Media {
Comment thread
kixelated marked this conversation as resolved.
Resolved
/// The file, relative to the scene file's directory unless absolute.
path: PathBuf,
/// Whether it starts over at its end, rather than holding its last
/// picture and going silent.
#[serde(default, rename = "loop", skip_serializing_if = "std::ops::Not::not")]
looping: bool,
/// Whether it plays from its start each time it goes on program.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
restart: bool,
},
/// A solid color.
Color {
/// The color, as `#rrggbb` or `#rrggbbaa`.
Expand Down Expand Up @@ -1288,18 +1302,18 @@ impl Source {
!matches!(self, Self::Microphone { .. } | Self::SystemAudio | Self::Lut { .. })
}

/// The file an image or LUT source reads.
/// The file an image, media, or LUT source reads.
pub(crate) fn path(&self) -> Option<&std::path::Path> {
match self {
Self::Image { path } | Self::Lut { path } => Some(path),
Self::Image { path } | Self::Media { path, .. } | Self::Lut { path } => Some(path),
_ => None,
}
}

/// The file the source reads, to resolve or relativize it.
pub(crate) fn path_mut(&mut self) -> Option<&mut PathBuf> {
match self {
Self::Image { path } | Self::Lut { path } => Some(path),
Self::Image { path } | Self::Media { path, .. } | Self::Lut { path } => Some(path),
_ => None,
}
}
Expand All @@ -1314,7 +1328,12 @@ impl Source {
pub fn has_audio(&self) -> bool {
matches!(
self,
Self::Microphone { .. } | Self::SystemAudio | Self::Broadcast { .. } | Self::Browser { .. } | Self::Replay
Self::Microphone { .. }
| Self::SystemAudio
| Self::Media { .. }
| Self::Broadcast { .. }
| Self::Browser { .. }
| Self::Replay
)
}

Expand Down Expand Up @@ -1498,6 +1517,14 @@ mod tests {
path: "studio/guest".into(),
},
);
collection.sources.insert(
"intro".into(),
Source::Media {
path: "intro.mkv".into(),
looping: false,
restart: true,
},
);
collection.sources.insert(
"chat".into(),
Source::Browser {
Expand Down Expand Up @@ -1833,7 +1860,16 @@ rect = { x = 0, y = 680, width = 1280, height = 40 }
collection.validate().unwrap();
collection.top.push(Item::new("grade", Rect::fill(&collection.canvas)));
assert!(matches!(collection.validate(), Err(Error::Invalid(_))));
for source in [Source::Lut { path: "".into() }, Source::Image { path: "".into() }] {
let media = Source::Media {
path: "".into(),
looping: true,
restart: false,
};
for source in [
Source::Lut { path: "".into() },
Source::Image { path: "".into() },
media,
] {
let mut collection = example();
collection.sources.insert("blank".into(), source.clone());
assert!(matches!(collection.validate(), Err(Error::Invalid(_))), "{source:?}");
Expand Down
8 changes: 7 additions & 1 deletion rs/moq-scene/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//! scenes and collections as text.
//! - [`source::Sources`] runs each source: a camera, display, window,
//! microphone, or system-audio capture (with the `capture` feature), a still
//! image, a solid color, a broadcast subscribed over MoQ, or a web page
//! image, a video or audio file, a solid color, a broadcast subscribed over MoQ, or a web page
//! rendered by a browser engine (the [`browser`] module). Pictures go to the
//! compositor and sound goes to a `moq_audio::bus::Bus`, routed into its
//! program mix.
Expand All @@ -37,6 +37,7 @@ mod compositor;
mod edit;
mod effect;
mod lut;
mod media;
mod overlay;
mod playout;
pub mod publish;
Expand Down Expand Up @@ -88,6 +89,11 @@ pub enum Error {
#[error("image: {0}")]
Image(String),

/// A media source's file could not be read, or holds nothing the studio
/// plays.
#[error("media: {0}")]
Media(String),

/// A LUT source could not be read or parsed.
#[error("LUT: {0}")]
Lut(String),
Expand Down
Loading