@mediaway/container 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,6 +89,44 @@ demuxer.close();
89
89
  WebM works the same way — pass `"webm"` to the `Muxer`/`Demuxer` constructor:
90
90
  `new Muxer("webm")` / `new Demuxer("webm")`.
91
91
 
92
+ ## Replay ring
93
+
94
+ `ReplayRing` keeps the last N milliseconds of encoded packets and cuts "the last M
95
+ milliseconds" at a keyframe of an anchor stream, in decode order, as a packet sequence
96
+ rebased to zero that a fresh `Muxer` writes as a standalone file. Other streams (audio) are
97
+ cut by time to match. Times are milliseconds.
98
+
99
+ ```ts
100
+ import { ReplayRing, Muxer } from "@mediaway/container";
101
+
102
+ const ring = ReplayRing.create({
103
+ anchorStreamId: 1, anchorTimeBase: { num: 1, den: 30 }, windowMs: 30_000, payload: "bytes",
104
+ });
105
+ ring.addStream(2, { num: 1, den: 48_000 });
106
+ ring.push({ streamId: 1, pts, dts, duration: 1, key, data }); // copies `data` in
107
+
108
+ const clip = ring.clipLast(10_000); // null until the anchor's first keyframe
109
+ // clip.entries(): { streamId, pts, dts (rebased), duration, isKeyframe, kind, payload }
110
+ // -> muxer.addVideoTrack / addAudioTrack once, then muxer.push({ ..., dts }) per entry
111
+ clip.close();
112
+ ```
113
+
114
+ - A **clip is an owned snapshot**: it stays valid while the ring keeps taking packets and
115
+ after the ring is closed. A `"bytes"` entry's `payload` is a **copy** out of the clip.
116
+ - It can start up to one keyframe interval **earlier** than asked, never later.
117
+ - `payload: "stored"` keeps only where the bytes are: `pushStored(meta, { file, offset,
118
+ length })`, with locations from `Muxer.createWithPlacements()` + `pollPlacements()` (the
119
+ offsets are file offsets when every `pollBytes` chunk is written sequentially from 0).
120
+ - Errors are distinct classes: `ReplayOutOfOrderError` (a stream's `dts` went backwards; the
121
+ packet was not added, drop it and carry on), `ReplayUnknownStreamError`,
122
+ `ReplayPayloadKindError` (`push` on a stored ring or `pushStored` on a bytes ring).
123
+ - **Limit:** the C ABI has no video-packet source (`EncodeSession` muxes its encoder's packets
124
+ internally; packet-level output exists only for the audio encoder). Feed the ring `Demuxer`
125
+ packets, audio-encoder packets, or packets from an encoder you drive yourself.
126
+
127
+ `Packet` now carries an optional `dts` (defaults to `pts` when pushing; always set on demuxed
128
+ packets), which a clip with B-frames needs.
129
+
92
130
  ## Other formats
93
131
 
94
132
  Ogg/ADTS/FLV/MPEG-TS/MP3/WAV are exported from the same package but have their own
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * structs out; the muxer never touches files — the caller owns byte I/O.
7
7
  * `pts`/`duration` are integer ticks of each track's `timeBase`.
8
8
  */
9
+ import type { Placement } from "./replay.js";
9
10
  export interface Rational {
10
11
  num: number;
11
12
  den: number;
@@ -46,6 +47,11 @@ export interface Packet {
46
47
  data: Buffer;
47
48
  /** Ticks of the track's timeBase. */
48
49
  pts: number;
50
+ /**
51
+ * Decode timestamp, ticks of the track's timeBase. Optional on input (`Muxer.push` defaults it
52
+ * to `pts`, i.e. no B-frames); always set on demuxed packets.
53
+ */
54
+ dts?: number;
49
55
  /** Ticks of the track's timeBase. */
50
56
  duration: number;
51
57
  /** Sync sample / keyframe. */
@@ -56,6 +62,22 @@ export declare class MediawayError extends Error {
56
62
  readonly status: number;
57
63
  constructor(status: number, message?: string);
58
64
  }
65
+ /**
66
+ * A replay ring refused a packet whose decode timestamp went backwards within its stream
67
+ * (status INVALID_PACKET). The packet was NOT added and the ring is still usable: drop it and
68
+ * carry on.
69
+ */
70
+ export declare class ReplayOutOfOrderError extends MediawayError {
71
+ constructor();
72
+ }
73
+ /** A packet for a stream that was never added to the replay ring (status UNKNOWN_STREAM). */
74
+ export declare class ReplayUnknownStreamError extends MediawayError {
75
+ constructor();
76
+ }
77
+ /** `push` on a `"stored"` ring, or `pushStored` on a `"bytes"` ring (status INVALID_STATE). */
78
+ export declare class ReplayPayloadKindError extends MediawayError {
79
+ constructor();
80
+ }
59
81
  export declare const CODEC_TO_ABI: Record<string, number>;
60
82
  export declare const ABI_TO_CODEC: Record<number, string>;
61
83
  export declare function check(status: number): void;
@@ -69,7 +91,26 @@ export declare function check(status: number): void;
69
91
  export declare class Muxer {
70
92
  private handle;
71
93
  private nextIndex;
72
- constructor(format?: ContainerFormat);
94
+ /**
95
+ * @param options.placements MP4 only: also record where every sample's payload lands in the
96
+ * output ({@link Muxer.pollPlacements}). The output bytes are identical; the only cost is
97
+ * one small record per sample.
98
+ */
99
+ constructor(format?: ContainerFormat, options?: {
100
+ placements?: boolean;
101
+ });
102
+ /** An MP4 muxer that records payload placements — see {@link Muxer.pollPlacements}. */
103
+ static createWithPlacements(): Muxer;
104
+ /**
105
+ * Take the placements recorded since the last call, in write order: where each sample's
106
+ * payload landed in the bytes {@link pollBytes} returned. Feed them to
107
+ * {@link ReplayRing.pushStored}. A placement's bytes are available from `pollBytes` by the
108
+ * time the placement is.
109
+ *
110
+ * @throws {MediawayError} status 2 (INVALID_STATE) before `begin()`, and on a muxer that
111
+ * records none (any not built with `placements`, WebM included).
112
+ */
113
+ pollPlacements(): Placement[];
73
114
  addVideoTrack(info: VideoTrackInfo): number;
74
115
  addAudioTrack(info: AudioTrackInfo): number;
75
116
  /**
@@ -101,6 +142,7 @@ export declare class Demuxer {
101
142
  pollPacket(): Packet | null;
102
143
  close(): void;
103
144
  }
145
+ export * from "./replay.js";
104
146
  export * from "./ogg.js";
105
147
  export * from "./adts.js";
106
148
  export * from "./flv.js";
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * structs out; the muxer never touches files — the caller owns byte I/O.
7
7
  * `pts`/`duration` are integer ticks of each track's `timeBase`.
8
8
  */
9
- import { MwPacket, MwPacketView, MwRational, MwStreamInfo, MwVideoTrackInfo, MwAudioTrackInfo, container, copyBytes, } from "@mediaway/ffi";
9
+ import { MwPacket, MwPacketView, MwRational, MwStreamInfo, MwVideoTrackInfo, MwAudioTrackInfo, container, copyBytes, replay, } from "@mediaway/ffi";
10
+ import { takePlacements } from "./replay.js";
10
11
  const FORMAT_TO_ABI = { mp4: 0, webm: 1 };
11
12
  /** Raw per-crate ABI status from mediaway-ffi (mediaway_status_t). */
12
13
  export class MediawayError extends Error {
@@ -16,6 +17,31 @@ export class MediawayError extends Error {
16
17
  this.status = status;
17
18
  }
18
19
  }
20
+ /**
21
+ * A replay ring refused a packet whose decode timestamp went backwards within its stream
22
+ * (status INVALID_PACKET). The packet was NOT added and the ring is still usable: drop it and
23
+ * carry on.
24
+ */
25
+ export class ReplayOutOfOrderError extends MediawayError {
26
+ constructor() {
27
+ super(4, "replay ring: dts went backwards on this stream; packet not added");
28
+ this.name = "ReplayOutOfOrderError";
29
+ }
30
+ }
31
+ /** A packet for a stream that was never added to the replay ring (status UNKNOWN_STREAM). */
32
+ export class ReplayUnknownStreamError extends MediawayError {
33
+ constructor() {
34
+ super(10, "replay ring: stream was never added");
35
+ this.name = "ReplayUnknownStreamError";
36
+ }
37
+ }
38
+ /** `push` on a `"stored"` ring, or `pushStored` on a `"bytes"` ring (status INVALID_STATE). */
39
+ export class ReplayPayloadKindError extends MediawayError {
40
+ constructor() {
41
+ super(2, "replay ring: wrong payload kind for this call (push vs pushStored)");
42
+ this.name = "ReplayPayloadKindError";
43
+ }
44
+ }
19
45
  export const CODEC_TO_ABI = {
20
46
  h264: 0,
21
47
  hevc: 1,
@@ -63,12 +89,40 @@ export function check(status) {
63
89
  export class Muxer {
64
90
  handle;
65
91
  nextIndex = 1;
66
- constructor(format = "mp4") {
67
- this.handle =
68
- format === "mp4" ? container.muxerCreate() : container.muxerCreateForFormat(FORMAT_TO_ABI[format]);
92
+ /**
93
+ * @param options.placements MP4 only: also record where every sample's payload lands in the
94
+ * output ({@link Muxer.pollPlacements}). The output bytes are identical; the only cost is
95
+ * one small record per sample.
96
+ */
97
+ constructor(format = "mp4", options = {}) {
98
+ if (options.placements) {
99
+ if (format !== "mp4")
100
+ throw new MediawayError(1, "placements are MP4-only");
101
+ this.handle = replay.muxerCreateWithPlacements();
102
+ }
103
+ else {
104
+ this.handle =
105
+ format === "mp4" ? container.muxerCreate() : container.muxerCreateForFormat(FORMAT_TO_ABI[format]);
106
+ }
69
107
  if (!this.handle)
70
108
  throw new MediawayError(7, "muxer creation panicked");
71
109
  }
110
+ /** An MP4 muxer that records payload placements — see {@link Muxer.pollPlacements}. */
111
+ static createWithPlacements() {
112
+ return new Muxer("mp4", { placements: true });
113
+ }
114
+ /**
115
+ * Take the placements recorded since the last call, in write order: where each sample's
116
+ * payload landed in the bytes {@link pollBytes} returned. Feed them to
117
+ * {@link ReplayRing.pushStored}. A placement's bytes are available from `pollBytes` by the
118
+ * time the placement is.
119
+ *
120
+ * @throws {MediawayError} status 2 (INVALID_STATE) before `begin()`, and on a muxer that
121
+ * records none (any not built with `placements`, WebM included).
122
+ */
123
+ pollPlacements() {
124
+ return takePlacements(this.handle);
125
+ }
72
126
  addVideoTrack(info) {
73
127
  const index = this.nextIndex++;
74
128
  const raw = {
@@ -111,7 +165,7 @@ export class Muxer {
111
165
  const raw = {
112
166
  stream_id: packet.trackIndex,
113
167
  pts: BigInt(packet.pts),
114
- dts: BigInt(packet.pts), // dts defaults to pts (no B-frames in the DX contract)
168
+ dts: BigInt(packet.dts ?? packet.pts), // defaults to pts (no B-frames)
115
169
  duration: BigInt(packet.duration),
116
170
  is_keyframe: packet.key ?? false,
117
171
  is_discard: false,
@@ -209,6 +263,7 @@ export class Demuxer {
209
263
  trackIndex,
210
264
  data: payload,
211
265
  pts: Number(raw.pts),
266
+ dts: Number(raw.dts),
212
267
  duration: Number(raw.duration),
213
268
  key: raw.is_keyframe,
214
269
  };
@@ -224,6 +279,7 @@ export class Demuxer {
224
279
  // Ogg/ADTS/FLV/MPEG-TS/MP3/WAV each need a genuinely different C ABI shape
225
280
  // from MP4/WebM's Muxer/Demuxer (no track registration, out-buffer-per-call
226
281
  // mux, or a construction-time stream list) — see each module's own top comment.
282
+ export * from "./replay.js";
227
283
  export * from "./ogg.js";
228
284
  export * from "./adts.js";
229
285
  export * from "./flv.js";
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Replay ring — keep the last N milliseconds of encoded packets in memory (or just their
3
+ * locations on disk) and cut "the last M milliseconds" at a keyframe, as a packet sequence
4
+ * rebased to zero that a fresh {@link Muxer} writes as a standalone file.
5
+ * (adr/container/0009-replay-ring-c-abi.md)
6
+ *
7
+ * **Where the packets come from:** the C ABI has no video-packet source. `EncodeSession` muxes
8
+ * its encoder's packets internally, and packet-level output exists only for the audio encoder.
9
+ * Feed the ring packets from a {@link Demuxer}, from the audio encoder, or from an encoder you
10
+ * drive yourself.
11
+ *
12
+ * Ring timestamps are ticks of each stream's own `timeBase`; the anchor stream's keyframes
13
+ * decide where a clip may start, and other streams (audio) are cut by time to match.
14
+ */
15
+ import { type Rational } from "./index.js";
16
+ /** What a ring holds for each packet. */
17
+ export type ReplayPayloadKind = "bytes" | "stored";
18
+ export interface ReplayRingOptions {
19
+ /** The stream whose keyframes decide where a clip may start (normally video). */
20
+ anchorStreamId: number;
21
+ /** The anchor's timebase. */
22
+ anchorTimeBase: Rational;
23
+ /** How much history to keep, in milliseconds. */
24
+ windowMs: number;
25
+ /**
26
+ * Evict oldest-GOP-first while more than this many payload bytes are held; 0 or omitted = no
27
+ * ceiling. The newest GOP is always kept, so one larger GOP can exceed it.
28
+ */
29
+ maxBytes?: number;
30
+ /**
31
+ * `"bytes"`: the ring copies each payload in ({@link ReplayRing.push}). `"stored"`: the ring
32
+ * keeps only where the bytes are ({@link ReplayRing.pushStored}), for a caller that already
33
+ * writes packets to a file.
34
+ */
35
+ payload: ReplayPayloadKind;
36
+ }
37
+ /** A packet to push into a `"bytes"` ring. Timestamps are ticks of the stream's timebase. */
38
+ export interface ReplayPacket {
39
+ streamId: number;
40
+ pts: number;
41
+ /** Decode timestamp. Must not go backwards within a stream. Defaults to `pts` (no B-frames). */
42
+ dts?: number;
43
+ duration: number;
44
+ key?: boolean;
45
+ data: Buffer;
46
+ }
47
+ /** A packet's metadata without its payload — input to {@link ReplayRing.pushStored}. */
48
+ export interface ReplayPacketMeta {
49
+ streamId: number;
50
+ pts: number;
51
+ dts?: number;
52
+ duration: number;
53
+ key?: boolean;
54
+ }
55
+ /** Where a packet's payload was stored on the caller's disk. */
56
+ export interface StoredLocation {
57
+ /** The caller's own id for the file. */
58
+ file: number;
59
+ /** Byte offset of the payload in that file (exact up to 2^53). */
60
+ offset: number;
61
+ /** Payload length in bytes. */
62
+ length: number;
63
+ }
64
+ interface ReplayEntryBase {
65
+ streamId: number;
66
+ /** Rebased so the cut keyframe decodes at zero. */
67
+ pts: number;
68
+ dts: number;
69
+ duration: number;
70
+ isKeyframe: boolean;
71
+ isDiscard: boolean;
72
+ }
73
+ /** One packet of a clip cut from a `"bytes"` ring. */
74
+ export interface ReplayBytesEntry extends ReplayEntryBase {
75
+ kind: "bytes";
76
+ /** A COPY of the payload: the clip's own pointer is borrowed and never leaves this package. */
77
+ payload: Buffer;
78
+ }
79
+ /** One packet of a clip cut from a `"stored"` ring. */
80
+ export interface ReplayStoredEntry extends ReplayEntryBase {
81
+ kind: "stored";
82
+ stored: StoredLocation;
83
+ }
84
+ export type ReplayEntry = ReplayBytesEntry | ReplayStoredEntry;
85
+ /**
86
+ * A clip: an OWNED SNAPSHOT of the ring at the moment {@link ReplayRing.clipLast} was called.
87
+ * Unlike the Rust `Clip` it does not borrow the ring, so it stays valid while the ring keeps
88
+ * taking packets and after the ring is closed. Release it with {@link close}.
89
+ */
90
+ export declare class ReplayClip {
91
+ private handle;
92
+ /** @internal Use {@link ReplayRing.clipLast}. */
93
+ constructor(handle: unknown);
94
+ private live;
95
+ /** Number of packets. */
96
+ get length(): number;
97
+ /** From the cut keyframe to the newest packet pushed when the clip was taken, in ms. */
98
+ get durationMs(): number;
99
+ /**
100
+ * Packet `index` in decode order across streams, timestamps rebased so the cut keyframe
101
+ * decodes at zero. A `"bytes"` entry's `payload` is a copy.
102
+ */
103
+ entry(index: number): ReplayEntry;
104
+ /** Every entry, in order. Copies each `"bytes"` payload once. */
105
+ entries(): ReplayEntry[];
106
+ [Symbol.iterator](): IterableIterator<ReplayEntry>;
107
+ /** Free the snapshot. Idempotent. */
108
+ close(): void;
109
+ }
110
+ /**
111
+ * A rolling buffer of the last `windowMs` of encoded packets.
112
+ *
113
+ * Push packets in decode order per stream; {@link clipLast} cuts at a keyframe of the anchor
114
+ * stream. Anchor packets before the anchor's first keyframe are dropped without error.
115
+ */
116
+ export declare class ReplayRing {
117
+ private handle;
118
+ private readonly kind;
119
+ private constructor();
120
+ /** Create a ring. */
121
+ static create(options: ReplayRingOptions): ReplayRing;
122
+ private live;
123
+ /** Carry another stream (e.g. audio), cut by time to match the anchor. */
124
+ addStream(streamId: number, timeBase: Rational): void;
125
+ /**
126
+ * Add a packet to a `"bytes"` ring, then evict whatever fell out of the window.
127
+ *
128
+ * The payload is COPIED into the ring (the native call only borrows it), and every clip then
129
+ * shares that copy.
130
+ *
131
+ * @throws {ReplayOutOfOrderError} this stream's `dts` went backwards; the packet was not added
132
+ * and the ring is still usable, so drop it and carry on.
133
+ * @throws {ReplayUnknownStreamError} the stream was never added.
134
+ * @throws {ReplayPayloadKindError} this is a `"stored"` ring.
135
+ */
136
+ push(packet: ReplayPacket): void;
137
+ /**
138
+ * Add a packet to a `"stored"` ring: its metadata and where its bytes are. The ring never
139
+ * reads the file and holds a few dozen bytes per packet. Take the location from
140
+ * {@link Muxer.pollPlacements} when every polled muxer byte is written to a file.
141
+ * Errors as {@link push}, with {@link ReplayPayloadKindError} on a `"bytes"` ring.
142
+ */
143
+ pushStored(meta: ReplayPacketMeta, stored: StoredLocation): void;
144
+ /** The longest clip {@link clipLast} can return now, in ms; 0 before the first keyframe. */
145
+ spanMs(): number;
146
+ /**
147
+ * Cut the last `spanMs` of every stream at a keyframe. The clip starts at the latest anchor
148
+ * keyframe at or before `newest - spanMs`: up to one keyframe interval EARLIER than asked,
149
+ * never later; with less than `spanMs` held it starts at the oldest keyframe.
150
+ *
151
+ * @returns the clip, or `null` until the anchor's first keyframe has been pushed.
152
+ */
153
+ clipLast(spanMs: number): ReplayClip | null;
154
+ /** Whether this ring holds payload bytes or locations. */
155
+ get payload(): ReplayPayloadKind;
156
+ /** Close the ring. Clips already taken stay valid. Idempotent. */
157
+ close(): void;
158
+ }
159
+ /** Where one muxed sample's payload landed in the output stream. */
160
+ export interface Placement {
161
+ /** The packet's track index (as passed to `Muxer.push`). */
162
+ trackIndex: number;
163
+ /** The sample's decode timestamp, as pushed. */
164
+ dts: number;
165
+ /**
166
+ * Absolute byte offset of the payload in the muxer's output: counted from the first byte
167
+ * `pollBytes` ever returned, so it is the file offset when every polled byte is written
168
+ * sequentially from 0. Exact up to 2^53.
169
+ */
170
+ offset: number;
171
+ /** Payload length as written: Annex-B becomes length-prefixed and ADTS is stripped. */
172
+ length: number;
173
+ }
174
+ /** @internal Take the muxer's placements; used by `Muxer.pollPlacements`. */
175
+ export declare function takePlacements(muxer: unknown): Placement[];
176
+ export {};
package/dist/replay.js ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Replay ring — keep the last N milliseconds of encoded packets in memory (or just their
3
+ * locations on disk) and cut "the last M milliseconds" at a keyframe, as a packet sequence
4
+ * rebased to zero that a fresh {@link Muxer} writes as a standalone file.
5
+ * (adr/container/0009-replay-ring-c-abi.md)
6
+ *
7
+ * **Where the packets come from:** the C ABI has no video-packet source. `EncodeSession` muxes
8
+ * its encoder's packets internally, and packet-level output exists only for the audio encoder.
9
+ * Feed the ring packets from a {@link Demuxer}, from the audio encoder, or from an encoder you
10
+ * drive yourself.
11
+ *
12
+ * Ring timestamps are ticks of each stream's own `timeBase`; the anchor stream's keyframes
13
+ * decide where a clip may start, and other streams (audio) are cut by time to match.
14
+ */
15
+ import { copyBytes, decodeArray, replay, } from "@mediaway/ffi";
16
+ import { MediawayError, ReplayOutOfOrderError, ReplayPayloadKindError, ReplayUnknownStreamError, check, } from "./index.js";
17
+ const PAYLOAD_BYTES = 0;
18
+ const PAYLOAD_STORED = 1;
19
+ /** Map the ring's statuses to distinct classes; everything else is the shared container error. */
20
+ function checkReplay(status) {
21
+ switch (status) {
22
+ case 0:
23
+ return;
24
+ case 2:
25
+ throw new ReplayPayloadKindError();
26
+ case 4:
27
+ throw new ReplayOutOfOrderError();
28
+ case 10:
29
+ throw new ReplayUnknownStreamError();
30
+ default:
31
+ check(status);
32
+ }
33
+ }
34
+ /**
35
+ * A clip: an OWNED SNAPSHOT of the ring at the moment {@link ReplayRing.clipLast} was called.
36
+ * Unlike the Rust `Clip` it does not borrow the ring, so it stays valid while the ring keeps
37
+ * taking packets and after the ring is closed. Release it with {@link close}.
38
+ */
39
+ export class ReplayClip {
40
+ handle;
41
+ /** @internal Use {@link ReplayRing.clipLast}. */
42
+ constructor(handle) {
43
+ this.handle = handle;
44
+ }
45
+ live() {
46
+ if (!this.handle)
47
+ throw new MediawayError(2, "replay clip is closed");
48
+ return this.handle;
49
+ }
50
+ /** Number of packets. */
51
+ get length() {
52
+ return Number(replay.clipPacketCount(this.live()));
53
+ }
54
+ /** From the cut keyframe to the newest packet pushed when the clip was taken, in ms. */
55
+ get durationMs() {
56
+ const out = [0n];
57
+ checkReplay(replay.clipDurationMs(this.live(), out));
58
+ return Number(out[0]);
59
+ }
60
+ /**
61
+ * Packet `index` in decode order across streams, timestamps rebased so the cut keyframe
62
+ * decodes at zero. A `"bytes"` entry's `payload` is a copy.
63
+ */
64
+ entry(index) {
65
+ const raw = {};
66
+ checkReplay(replay.clipPacketAt(this.live(), index, raw));
67
+ const base = {
68
+ streamId: raw.stream_id,
69
+ pts: Number(raw.pts),
70
+ dts: Number(raw.dts),
71
+ duration: Number(raw.duration),
72
+ isKeyframe: raw.is_keyframe,
73
+ isDiscard: raw.is_discard,
74
+ };
75
+ if (raw.payload_kind === PAYLOAD_STORED) {
76
+ return {
77
+ ...base,
78
+ kind: "stored",
79
+ stored: { file: raw.stored_file, offset: Number(raw.stored_offset), length: raw.stored_len },
80
+ };
81
+ }
82
+ return { ...base, kind: "bytes", payload: copyBytes(raw.payload, raw.payload_len) };
83
+ }
84
+ /** Every entry, in order. Copies each `"bytes"` payload once. */
85
+ entries() {
86
+ const n = this.length;
87
+ const out = [];
88
+ for (let i = 0; i < n; i++)
89
+ out.push(this.entry(i));
90
+ return out;
91
+ }
92
+ *[Symbol.iterator]() {
93
+ const n = this.length;
94
+ for (let i = 0; i < n; i++)
95
+ yield this.entry(i);
96
+ }
97
+ /** Free the snapshot. Idempotent. */
98
+ close() {
99
+ if (this.handle) {
100
+ replay.clipFree(this.handle);
101
+ this.handle = null;
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * A rolling buffer of the last `windowMs` of encoded packets.
107
+ *
108
+ * Push packets in decode order per stream; {@link clipLast} cuts at a keyframe of the anchor
109
+ * stream. Anchor packets before the anchor's first keyframe are dropped without error.
110
+ */
111
+ export class ReplayRing {
112
+ handle;
113
+ kind;
114
+ constructor(handle, kind) {
115
+ this.handle = handle;
116
+ this.kind = kind;
117
+ }
118
+ /** Create a ring. */
119
+ static create(options) {
120
+ const config = {
121
+ anchor_stream_id: options.anchorStreamId,
122
+ anchor_time_base: { num: BigInt(options.anchorTimeBase.num), den: options.anchorTimeBase.den },
123
+ window_ms: BigInt(options.windowMs),
124
+ max_bytes: BigInt(options.maxBytes ?? 0),
125
+ payload_kind: options.payload === "stored" ? PAYLOAD_STORED : PAYLOAD_BYTES,
126
+ };
127
+ const out = [null];
128
+ checkReplay(replay.ringCreate(config, out));
129
+ return new ReplayRing(out[0], options.payload);
130
+ }
131
+ live() {
132
+ if (!this.handle)
133
+ throw new MediawayError(2, "replay ring is closed");
134
+ return this.handle;
135
+ }
136
+ /** Carry another stream (e.g. audio), cut by time to match the anchor. */
137
+ addStream(streamId, timeBase) {
138
+ checkReplay(replay.ringAddStream(this.live(), streamId, { num: BigInt(timeBase.num), den: timeBase.den }));
139
+ }
140
+ /**
141
+ * Add a packet to a `"bytes"` ring, then evict whatever fell out of the window.
142
+ *
143
+ * The payload is COPIED into the ring (the native call only borrows it), and every clip then
144
+ * shares that copy.
145
+ *
146
+ * @throws {ReplayOutOfOrderError} this stream's `dts` went backwards; the packet was not added
147
+ * and the ring is still usable, so drop it and carry on.
148
+ * @throws {ReplayUnknownStreamError} the stream was never added.
149
+ * @throws {ReplayPayloadKindError} this is a `"stored"` ring.
150
+ */
151
+ push(packet) {
152
+ const raw = {
153
+ stream_id: packet.streamId,
154
+ pts: BigInt(packet.pts),
155
+ dts: BigInt(packet.dts ?? packet.pts),
156
+ duration: BigInt(packet.duration),
157
+ is_keyframe: packet.key ?? false,
158
+ is_discard: false,
159
+ payload: packet.data,
160
+ payload_len: packet.data.length,
161
+ };
162
+ checkReplay(replay.ringPush(this.live(), raw));
163
+ }
164
+ /**
165
+ * Add a packet to a `"stored"` ring: its metadata and where its bytes are. The ring never
166
+ * reads the file and holds a few dozen bytes per packet. Take the location from
167
+ * {@link Muxer.pollPlacements} when every polled muxer byte is written to a file.
168
+ * Errors as {@link push}, with {@link ReplayPayloadKindError} on a `"bytes"` ring.
169
+ */
170
+ pushStored(meta, stored) {
171
+ checkReplay(replay.ringPushStored(this.live(), {
172
+ stream_id: meta.streamId,
173
+ pts: BigInt(meta.pts),
174
+ dts: BigInt(meta.dts ?? meta.pts),
175
+ duration: BigInt(meta.duration),
176
+ is_keyframe: meta.key ?? false,
177
+ is_discard: false,
178
+ }, { file: stored.file, offset: BigInt(stored.offset), len: stored.length }));
179
+ }
180
+ /** The longest clip {@link clipLast} can return now, in ms; 0 before the first keyframe. */
181
+ spanMs() {
182
+ const out = [0n];
183
+ checkReplay(replay.ringSpanMs(this.live(), out));
184
+ return Number(out[0]);
185
+ }
186
+ /**
187
+ * Cut the last `spanMs` of every stream at a keyframe. The clip starts at the latest anchor
188
+ * keyframe at or before `newest - spanMs`: up to one keyframe interval EARLIER than asked,
189
+ * never later; with less than `spanMs` held it starts at the oldest keyframe.
190
+ *
191
+ * @returns the clip, or `null` until the anchor's first keyframe has been pushed.
192
+ */
193
+ clipLast(spanMs) {
194
+ const clip = [null];
195
+ const has = [false];
196
+ checkReplay(replay.ringClipLast(this.live(), BigInt(spanMs), clip, has));
197
+ return has[0] ? new ReplayClip(clip[0]) : null;
198
+ }
199
+ /** Whether this ring holds payload bytes or locations. */
200
+ get payload() {
201
+ return this.kind;
202
+ }
203
+ /** Close the ring. Clips already taken stay valid. Idempotent. */
204
+ close() {
205
+ if (this.handle) {
206
+ replay.ringClose(this.handle);
207
+ this.handle = null;
208
+ }
209
+ }
210
+ }
211
+ /** @internal Take the muxer's placements; used by `Muxer.pollPlacements`. */
212
+ export function takePlacements(muxer) {
213
+ const rows = [null];
214
+ const count = [0];
215
+ checkReplay(replay.muxerPollPlacements(muxer, rows, count));
216
+ const n = Number(count[0]);
217
+ const decoded = decodeArray(rows[0], replay.structs.MwPlacement, n);
218
+ const out = decoded.map((p) => ({
219
+ trackIndex: p.track_id,
220
+ dts: Number(p.dts),
221
+ offset: Number(p.offset),
222
+ length: p.len,
223
+ }));
224
+ if (n > 0)
225
+ replay.placementsFree(rows[0], n);
226
+ return out;
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediaway/container",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "description": "Rust-based, lightweight, fast — sans-IO fragmented MP4 (fMP4) mux and demux for Node.js — push packets in, poll bytes out",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",