MoQ is under active development. APIs will change, but we keep backwards wire compatibility.

Skip to content

@moq/publish ​

npm

The publisher: captures a camera, microphone, screen, or file, encodes with WebCodecs, writes the catalog, and publishes a hang broadcast.

html
<script type="module">
    import "@moq/publish/element";
    import "@moq/publish/ui";     // optional device picker and controls
</script>

<moq-publish-ui>
    <moq-publish url="https://relay.example.com/anon" name="room/alice.hang" source="camera">
        <video muted autoplay></video>
    </moq-publish>
</moq-publish-ui>

Attributes ​

Attribute
url, nameRelay URL (with ?jwt= if needed) and broadcast name.
sourcecamera, screen, or file.
muted, invisibleDisable audio or video capture.
previewWhat the nested element shows: the raw source (default), a decoded copy of the encoded stream to see what viewers get, or none.
announceWhen to advertise: once a source is live (default), always, or never. A camera source waits for every enabled track, and source waits until each captured track's config resolves or fails, so the first catalog lists every rendition. The broadcast is created while connected either way, but nobody can see or subscribe to it until it is announced.

A nested <video> gets the raw capture stream; a <canvas> is drawn by the element. <moq-publish-support> shows what the browser can encode.

Camera and microphone failures are readable through the element's el.sources.video and el.sources.audio signals. When these hold a Publish.Source.Camera or Publish.Source.Microphone, their out.error signal contains an Error, such as NotAllowedError when permission is refused, and clears when capture restarts. A refused capture waits for a permission, device, constraint, or enabled-state change instead of repeatedly prompting. <moq-publish-ui> displays the failure in its status badge.

Encoding ​

The video encoder follows its share of the connection's send-rate estimate (via Bandwidth.Allocator), so several publishers on one connection do not each target the whole uplink. Audio reserves its configured bitrate so video's share is honest, and keeps encoding at that rate. Codec, resolution, framerate, and bitrate are tunable through el.video.config; the audio encoder exposes its codec and volume. For simulcast or several renditions, drop the element and register your own encoders on a Publish.Broadcast.

Every audio volume change ramps over el.audio.fade, 50ms by default, so volume = 0 is silent once the fade passes. A fade of 0 steps at once; a negative or non-finite fade drops the rendition until it is fixed. Disabling a rendition (muted on the element) ends the audio timeline with a marker, so a viewer that stays subscribed, or joins during the pause, never plays the audio before it as live.

el.video.cut() asks for a keyframe on top of the keyframeInterval cadence, for a resume, a recording cut, or a known tune-in moment. Requests coalesce into the next keyframe, and forced keyframes land at least 500ms apart.

A still source, such as a screen share of an unchanging slide, delivers a frame only when its picture changes. Video.Capture holds the newest frame and opens every new reader with a copy stamped at the moment it attaches, so a viewer or recorder that subscribes later still gets the current picture as a keyframe.

The video and audio encoders measure how far their output falls behind the media clock when they flush frames. Catalog jitter is the spread above each rendition's own recent minimum lateness, so a constant encoder delay is not jitter. The advertised value only rises; frame duration alone does not set it. The first estimate rise publishes immediately. Later rises within a second coalesce into one update at the end of that window carrying the latest value. Track additions, removals, and configuration edits publish immediately, including any pending estimate.

Clock ​

Every timestamp the publisher writes is performance.now() in microseconds, so camera, microphone, screen, and file sources share one timeline. The catalog advertises that mapping as its root clock from the first snapshot, with PTS zero at performance.timeOrigin, so a viewer or an HLS export can name any frame's wall time. The mapping is fixed for the page: a system-clock adjustment never retimes the broadcast. Stamp your own tracks (e.g. text cues) on the same timeline to stay in sync.

Custom tracks ​

broadcast.net is the underlying Moq.Broadcast.Producer, so an application can serve its own tracks alongside the media. It is recreated on each (re)connection, so acquire it from an effect and reseed the track each time:

ts
import * as Json from "@moq/json";

signals.run((effect) => {
    const net = effect.get(broadcast.net);
    if (!net) return;

    // A day-long retention so a late viewer still replays the last value.
    const track = net.createTrack("meta.json", { maxAge: 86_400_000 });
    effect.cleanup(() => track.close());

    const meta = new Json.Snapshot.Producer<Meta>({ track });
    meta.update(current);
});

broadcast.catalog.mutate(c => { c.yourSection = ... }) advertises it without touching the media sections, which are folded in from the registered renditions. The catalog schema is loose, so an unknown root section passes through untouched; cast to name your own:

ts
broadcast.catalog.mutate((catalog) => {
    (catalog as Catalog.Root & { metadata?: string[] }).metadata = ["meta.json"];
});

Without the element ​

ts
import * as Moq from "@moq/net";
import * as Publish from "@moq/publish";

// Shared with every other component pointed at the same relay; its origin holds
// the broadcasts, so they survive a reconnect.
const connection = new Moq.Connection({ url: new URL("https://relay.example.com/anon") });

const broadcast = new Publish.Broadcast({
    origin: connection.origin,
    enabled: true,
    name: Publish.Net.Path.from("alice.hang"),
});

const camera = new Publish.Source.Camera({ enabled: true });
const microphone = new Publish.Source.Microphone({ enabled: true });
const video = new Publish.Signals.Computed((effect) => effect.get(camera.out.source)?.video);
const capture = new Publish.Video.Capture({ source: video });

// Each encoder registers a rendition on the broadcast (`broadcast.video(name)`) and
// encodes only while someone is subscribed.
new Publish.Video.Encoder("video/hd", { broadcast, capture, enabled: true });
new Publish.Video.Encoder("video/sd", { broadcast, capture, enabled: true, config: { maxScale: 0.25 } });
const audioSource = new Publish.Signals.Computed((effect) => effect.get(microphone.out.source)?.audio);
const audioCapture = new Publish.Audio.Capture({ source: audioSource });
new Publish.Audio.Encoder("audio", { broadcast, capture: audioCapture, enabled: true });

Standalone components start enabled unless you pass enabled: false (or a signal). Camera and microphone sources may prompt for permission on construction, so build an enabled screen source inside the user gesture that authorizes screen capture. Audio capture that starts before the page's first click or keypress waits for one: browsers suspend Web Audio until then, and the audio rendition stays out of the catalog until samples flow.

Every input and output is a signal from @moq/signals. Load from a CDN (https://esm.sh/@moq/publish/element) for a no-build embed.

Strict CSP ​

The audio worklet and the capture worker load from blob: URLs by default, so they need no hosted files but a CSP must allow blob: in script-src and worker-src. For a CSP that refuses blob:, copy node_modules/@moq/publish/assets/* into a directory your origin serves, and point the package at it before capture starts:

ts
import * as Publish from "@moq/publish";

Publish.assets("/moq/");

The URL must end with /. Copy the files again on every upgrade: they change with the package. The capture worker only runs where the main thread lacks MediaStreamTrackProcessor (Firefox and Safari); if the hosted file fails to load, capture errors instead of hiding the broken deploy. @moq/room publishes through @moq/publish, so this one call covers it.

Licensed under MIT or Apache-2.0