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
6 changes: 3 additions & 3 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ service; `export ts --passthrough` writes it back out. FLV accepts the same
legacy and enhanced-RTMP codecs as the [RTMP gateway](/bin/rtmp).

fMP4 import splits each fragment into one frame per sample, so players need no
MP4 parser. `import fmp4 --passthrough` publishes each fragment whole as
MP4 parser. `import fmp4 --cmaf` publishes each fragment whole as
[`cmaf`](/concept/hang#container) instead, keeping the source's exact sample
timing.

Expand Down Expand Up @@ -418,7 +418,7 @@ ending with `EXT-X-ENDLIST`. A dropped connection or an error closes the
broadcast at once, and a reconnect, which publishes a newer
[epoch](/concept/moq-lite#publisher-epochs) at the same path, ends the wait.

`export ts`, `export flv`, and `export mkv` take `--delay` instead, like an SRT
`export ts`, `export flv`, `export rtmp`, and `export mkv` take `--delay` instead, like an SRT
receiver's latency, all tracks in decode order, so two exporters of one broadcast
emit the same order. FLV and MKV mux each frame that long after its decode time
(default 500 ms). TS output runs that far behind the source (default 1 s): the
Expand All @@ -429,7 +429,7 @@ top. A frame arriving after its deadline is dropped, and video resumes at its
next keyframe. The delay also bounds how stale a group may get, so `--delay 0`
writes each frame as soon as it is read and drops none as late, but still skips
a stalled group once a newer one is ready.
FLV and MKV reject their former `--max-delay` spelling with a migration
FLV, MKV and RTMP refuse the released `--max-age` spelling with a migration
message. The output follows the source's clock within what ISO/IEC 13818-1
allows a TS PCR. A source further off fails the export, naming its rate, once
it has used half the delay; a brief network stall only drops what it made late.
Expand Down
19 changes: 9 additions & 10 deletions doc/bin/gstreamer.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@ Each `sink_%u` request pad is one track. Pad properties: `track` names it
[LOC](/concept/standard#loc) instead of the legacy hang container,
`encoder=true` marks it as fed by a local encoder, and
`track-status`/`track-error` report its lifecycle. Element properties:
`url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keylog`,
`quic-keep-alive`, `linger`, and read-only `status`, `connected`, `moq-version`, and
`url`, `broadcast`, `connect-version`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keylog`,
`quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and
`estimated-send-rate`, `estimated-recv-rate`, `connection-stats`, and
`sessions`. The sink reconnects for as long as the pipeline runs and only
reports `failed` on an answer redialing can't change, such as a rejected token.
Both elements accept the same connection properties. `connect-version` is a
comma-separated list, matching the CLI's repeatable `--connect-version`;
unknown versions fail startup. QUIC durations are milliseconds, with zero
disabling the local idle timeout or keep-alive.
Each run from `READY` publishes under a fresh
[publisher epoch](/concept/moq-lite#publisher-epochs), so on moq-lite 07 (opt-in)
or moq-transport draft 17 and later, viewers switch to a restarted pipeline at once instead of stalling on the old
Expand All @@ -77,12 +81,7 @@ it is released, reaches EOS, or fails; a flush or new stream after that EOS
holds it back again. Opaque data pads are not listed. Later
catalog updates do not wait for newly requested pads.

Once every pad reaches EOS, the sink finishes the broadcast and posts EOS.
With `linger=true` it first keeps serving the finished broadcast until its
media is stale (30 seconds for audio and video), so a viewer still catching up
gets the tail, and posts EOS only then, which is when `gst-launch-1.0 -e`
exits. Stopping the pipeline, such as a second Ctrl-C under `-e`, closes the
broadcast at once.
Once every pad reaches EOS, the sink closes the broadcast and posts EOS.

All pads share one media timeline on one wall clock. The first timed buffer places
it using UNIX or NTP reference timestamp metadata; other clock domains use the
Expand Down Expand Up @@ -143,9 +142,9 @@ on its own. Losing the relay connection also holds the pads: `moqsrc` redials
without a retry deadline and resumes when the broadcast returns. A refused
connection, refused path or catalog, or malformed protocol input or catalog posts
an error. Properties: `url`,
`broadcast`, `versions` (comma-separated protocol versions to offer, most
`broadcast`, `connect-version` (comma-separated protocol versions to offer, most
preferred first; every stable version by default), `tls-disable-verify`,
`quic-keylog`.
`quic-idle-timeout`, `quic-keep-alive`, and `quic-keylog`.

Debug with `GST_DEBUG=*:4` for GStreamer and `RUST_LOG=debug` for the plugin.
To decrypt a capture, set `quic-keylog=true` on either element along with
Expand Down
5 changes: 3 additions & 2 deletions doc/bin/hls.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ returns HTTP 400. A live-only timeline or range past a finished recording
returns HTTP 416 instead of a partial clip, and a failed timeline returns 500.

`--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS,
and `--cors-origin` opens it to browsers. See `moq export hls --help`.
and `--cors-origin` opens it to browsers. HLS TLS reads `MOQ_HLS_TLS_*`;
`MOQ_LISTEN_TLS_*` configures only the MoQ listener. See `moq export hls --help`.

## Import

Import handles classic HLS; LL-HLS parts are not implemented. It publishes the
playlist's own media times, and anchors the catalog clock on the first imported
segment. Each fMP4 segment is split into one frame per sample; `--passthrough`
segment. Each fMP4 segment is split into one frame per sample; `--cmaf`
publishes each fragment whole as CMAF instead, keeping its exact sample timing.

The library, including the recorder cursors for mirroring a broadcast to
Expand Down
2 changes: 1 addition & 1 deletion doc/bin/rtmp.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ and anchors the catalog clock on the first frame, so it names the wall time the
push arrived. A group starting before the previous group's start, such as an
encoder restarting its timestamps mid-push, ends that push with an error.

Egress uses `--max-delay` (default 500 ms) for both the FLV release delay and
Egress uses `--delay` (default 500 ms) for both the FLV release delay and
the stalled-group budget. Frames go out in decode order; a late video frame
is dropped and video resumes at the next keyframe. Zero releases available
frames immediately without deadline drops.
Expand Down
1 change: 0 additions & 1 deletion doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ WebCodecs, writes the catalog, and publishes a hang broadcast.
| `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. |
| `backend` | How a `<canvas>` preview is drawn; see [@moq/video](/lib/js/video). `auto` (default), `webgpu`, or `2d`. |
| `announce` | When to advertise: once the `source` is live (default), `always`, or `never`. Nobody can see or subscribe to the broadcast until it is announced. |
| `linger` | Finish the broadcast cleanly when advertising stops, such as a `source` that ends, instead of unannouncing it at once. It stays up while its media is fresh so viewers drain the tail, and the next announce publishes a new broadcast. Removing the element ends it at once. Off by default. |

Every attribute is also a reactive property. The
[README](https://www.npmjs.com/package/@moq/publish) lists types and defaults.
Expand Down
1 change: 0 additions & 1 deletion js/publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ The simplest way to publish a stream:
| `preview` | string | `"source"` | What the preview renders: `"source"`, `"encoded"`, `"none"` |
| `backend` | string | `"auto"` | Graphics API for a `<canvas>` preview: `"auto"`, `"webgpu"`, `"2d"` |
| `announce` | string | `"source"` | When to advertise: `"always"`, `"never"`, `"source"` (once media is captured). 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. |
| `linger` | boolean | false | When advertising stops, finish the broadcast cleanly instead of unannouncing it: it stays up while its media is fresh so viewers drain the tail, and the next announce publishes a new one. |

A nested `<video>` shows the raw capture; a `<canvas>` is drawn by the element.
Camera and microphone failures are observable through `el.sources.video`
Expand Down
182 changes: 3 additions & 179 deletions js/publish/src/broadcast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import { Manual, Milli, Nano } from "@moq/clock";
import * as Catalog from "@moq/hang/catalog";
import * as Json from "@moq/json";
import { Origin, Path, Time, Track } from "@moq/net";
import { Origin, Path, Track } from "@moq/net";
import { Effect, Signal } from "@moq/signals";
import { Broadcast } from "./broadcast.ts";

Expand Down Expand Up @@ -228,68 +228,9 @@ test("serves no catalog until announced, so the first snapshot is the one at ann
broadcast.close();
});

// With `linger`, a source that stops ends the publish cleanly: the finished broadcast stays announced
// while its media is fresh, so a viewer still catching up drains the tail, then it closes.
test("stopping the announce with linger serves the finished broadcast until its media is stale", async () => {
const clock = new Manual();
const installed = clock.install();
const origin = new Origin.Producer();
try {
const announce = new Signal(true);
const broadcast = new Broadcast({
enabled: true,
origin,
name: Path.from("test.hang"),
announce,
linger: true,
});
const video = broadcast.video("video");
video.config.set(videoConfig);
await settle();

const net = broadcast.net.peek();
if (!net) throw new Error("expected a network producer once connected");
const subscriber = net.track("video").subscribe();
await settle();
const track = video.track.peek();
if (!track) throw new Error("expected the rendition track once subscribed");
const group = track.appendGroup();
group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() });
group.close();

announce.set(false);
await settle();
expect(broadcast.net.peek()).toBeUndefined();
expect(net.route).toBeDefined();
const late = net.track("video").subscribe().ordered();
expect((await late.readFrame())?.payload).toEqual(new Uint8Array([1]));
const catalog = net.track(Broadcast.CATALOG_TRACK).subscribe();
expect(
(await new Json.Snapshot.Consumer<Catalog.Root>({ track: catalog }).latest())?.value.video,
).toBeDefined();

await clock.advance(Nano.fromMilli(Milli(30_000)));
expect(net.closed.peek()).toBeNull();
expect(net.route).toBeUndefined();
// A fresh broadcast waits unannounced for the next announce.
const next = broadcast.net.peek();
expect(next).toBeDefined();
expect(next).not.toBe(net);
expect(next?.route).toBeUndefined();

late.close();
catalog.close();
subscriber.close();
broadcast.close();
} finally {
origin.close();
installed.close();
}
});

// Regression: a re-announce re-created the catalog track, restarting its group sequence under the
// same epoch, so a resumed viewer mistook the new catalog for the old one. Without `linger`, turning
// the announce off unannounces at once and keeps the broadcast for the next announce.
// same epoch, so a resumed viewer mistook the new catalog for the old one. Turning the announce off
// unannounces at once and keeps the broadcast for the next announce.
test("a re-announce continues the catalog's group sequence", async () => {
const announce = new Signal(true);
const broadcast = new Broadcast({
Expand Down Expand Up @@ -325,123 +266,6 @@ test("a re-announce continues the catalog's group sequence", async () => {
broadcast.close();
});

// With `linger`, a re-announce publishes a new broadcast under a new epoch, replacing the finished
// one at once.
test("announcing again replaces a finished broadcast with a new one", async () => {
const announce = new Signal(true);
const broadcast = new Broadcast({
enabled: true,
origin: new Origin.Producer(),
name: Path.from("test.hang"),
announce,
linger: true,
});
broadcast.video("video").config.set(videoConfig);
await settle();

const net = broadcast.net.peek();
if (!net) throw new Error("expected a network producer once connected");
const epoch = net.route?.epoch;
expect(epoch).toBeDefined();

announce.set(false);
await settle();
broadcast.audio("audio").config.set(audioConfig);
await settle();
expect(net.closed.peek()).toBeUndefined();
announce.set(true);
await settle();

expect(net.closed.peek()).toBeNull();
const next = broadcast.net.peek();
if (!next) throw new Error("expected a new network producer");
expect(next).not.toBe(net);
expect(next.route?.epoch).toBeDefined();
expect(next.route?.epoch).not.toBe(epoch);
const group = await next.track(Broadcast.CATALOG_TRACK).subscribe().ordered().nextGroup();
const catalog = (await group?.readJson()) as Catalog.Root;
expect(Object.keys(catalog.audio?.renditions ?? {})).toEqual(["audio"]);

broadcast.close();
});

// Disabling, renaming, or closing the publisher is abrupt: it ends a finished broadcast's linger at once.
test("disabling, renaming, or closing ends a finished broadcast at once", async () => {
const enabled = new Signal(true);
const announce = new Signal(true);
const name = new Signal(Path.from("test.hang"));
const origin = new Origin.Producer();
const broadcast = new Broadcast({ enabled, origin, name, announce, linger: true });
broadcast.video("video").config.set(videoConfig);
await settle();

const first = broadcast.net.peek();
if (!first) throw new Error("expected a network producer once connected");
announce.set(false);
await settle();
expect(first.closed.peek()).toBeUndefined();
enabled.set(false);
await settle();
expect(first.closed.peek()).toBeNull();

enabled.set(true);
announce.set(true);
await settle();
const second = broadcast.net.peek();
if (!second) throw new Error("expected a network producer once re-enabled");
announce.set(false);
await settle();
expect(second.closed.peek()).toBeUndefined();
name.set(Path.from("renamed.hang"));
await settle();
expect(second.closed.peek()).toBeNull();

announce.set(true);
await settle();
const third = broadcast.net.peek();
if (!third) throw new Error("expected a network producer once announced again");
announce.set(false);
await settle();
expect(third.closed.peek()).toBeUndefined();
broadcast.close();
expect(third.closed.peek()).toBeNull();
origin.close();
});

// Regression: a rename or maxAge change in the same tick the announce stopped coalesced with it, so
// the old broadcast finished and kept its path instead of ending at once.
test("a rename as the announce stops ends the old broadcast at once", async () => {
const announce = new Signal(true);
const name = new Signal(Path.from("test.hang"));
const maxAge = new Signal<Milli | undefined>(undefined);
const origin = new Origin.Producer();
const broadcast = new Broadcast({ enabled: true, origin, name, announce, maxAge, linger: true });
broadcast.video("video").config.set(videoConfig);
await settle();

const first = broadcast.net.peek();
if (!first) throw new Error("expected a network producer once connected");
announce.set(false);
name.set(Path.from("renamed.hang"));
await settle();
expect(first.closed.peek()).toBeNull();
const second = broadcast.net.peek();
if (!second) throw new Error("expected a network producer under the new name");
expect(second).not.toBe(first);

announce.set(true);
await settle();
announce.set(false);
maxAge.set(Milli(1_000));
await settle();
expect(second.closed.peek()).toBeNull();
expect(broadcast.net.peek()).toBeDefined();
expect(broadcast.net.peek()).not.toBe(second);

broadcast.close();
origin.close();
});

// Regression: any rendition change re-created every rendition's track, so the next append restarted
// a live track's group sequence.
test("a rendition keeps its track across rendition changes", async () => {
Expand Down
Loading
Loading