@eventual-ai/mcap-viewer 0.1.0-alpha.0 → 0.1.0-alpha.2

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.
Files changed (51) hide show
  1. package/README.md +15 -14
  2. package/dist/{types/core → core}/index.d.ts +2 -2
  3. package/dist/core/index.js +3 -0
  4. package/dist/core/mcap/codecs/h264.js +22 -0
  5. package/dist/{types/core → core}/mcap/codecs/video.d.ts +2 -1
  6. package/dist/core/mcap/codecs/video.js +64 -0
  7. package/dist/{types/core → core}/mcap/index.d.ts +1 -1
  8. package/dist/core/mcap/index.js +2 -0
  9. package/dist/core/mcap/loading.js +50 -0
  10. package/dist/{types/core → core}/mcap/reader.d.ts +6 -1
  11. package/dist/core/mcap/reader.js +141 -0
  12. package/dist/{types/core → core}/mcap/recording.d.ts +8 -5
  13. package/dist/core/mcap/recording.js +72 -0
  14. package/dist/core/mcap/source.js +67 -0
  15. package/dist/{types/core → core}/mcap/types.d.ts +3 -3
  16. package/dist/core/mcap/types.js +1 -0
  17. package/dist/{types/core → core}/playback/index.d.ts +1 -1
  18. package/dist/core/playback/index.js +1 -0
  19. package/dist/{types/core → core}/playback/player.d.ts +8 -2
  20. package/dist/core/playback/player.js +344 -0
  21. package/dist/{types/core → core}/playback/snapshot.d.ts +2 -2
  22. package/dist/core/playback/snapshot.js +11 -0
  23. package/dist/{types/core → core}/playback/video-sequence.d.ts +7 -7
  24. package/dist/core/playback/video-sequence.js +116 -0
  25. package/dist/{types/core → core}/renderers/image.d.ts +2 -0
  26. package/dist/core/renderers/image.js +165 -0
  27. package/dist/core/renderers/index.js +1 -0
  28. package/dist/{types/core → core}/renderers/video-decoder.d.ts +4 -1
  29. package/dist/core/renderers/video-decoder.js +146 -0
  30. package/dist/core/schemas.d.ts +19 -0
  31. package/dist/core/schemas.js +13 -0
  32. package/dist/data-loader/index.d.ts +2 -0
  33. package/dist/data-loader/index.js +1 -0
  34. package/dist/{types/data-loader → data-loader}/loader.d.ts +4 -4
  35. package/dist/data-loader/loader.js +44 -0
  36. package/dist/{types/data-loader → data-loader}/types.d.ts +3 -3
  37. package/dist/data-loader/types.js +1 -0
  38. package/dist/index.js +2 -0
  39. package/package.json +17 -16
  40. package/dist/THIRD_PARTY_LICENSES.md +0 -415
  41. package/dist/core.js +0 -534
  42. package/dist/data-loader.js +0 -53
  43. package/dist/mcap-viewer.js +0 -11
  44. package/dist/recording-tVYbg6KM.js +0 -9109
  45. package/dist/types/core/schemas.d.ts +0 -15
  46. package/dist/types/data-loader/index.d.ts +0 -2
  47. /package/dist/{types/core → core}/mcap/codecs/h264.d.ts +0 -0
  48. /package/dist/{types/core → core}/mcap/loading.d.ts +0 -0
  49. /package/dist/{types/core → core}/mcap/source.d.ts +0 -0
  50. /package/dist/{types/core → core}/renderers/index.d.ts +0 -0
  51. /package/dist/{types/index.d.ts → index.d.ts} +0 -0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @eventual-ai/mcap-viewer
2
2
 
3
3
  A TypeScript browser SDK for playing native H.264/H.265 video from streamed MCAP
4
- recordings. `/core` plays MCAP video topics; `/data-loader` handles the replay
5
- service. The package root exports both.
4
+ recordings. `/core` plays MCAP video topics; `/data-loader` handles the MCAP
5
+ Delivery API (MDA). The package root exports both.
6
6
 
7
7
  ```sh
8
8
  npm install @eventual-ai/mcap-viewer@alpha
@@ -11,12 +11,12 @@ npm install @eventual-ai/mcap-viewer@alpha
11
11
  ```ts
12
12
  import {
13
13
  McapRecording,
14
- ReplayPlayer,
14
+ Player,
15
15
  ImageRenderer,
16
16
  } from "@eventual-ai/mcap-viewer/core";
17
17
 
18
18
  const recording = await McapRecording.open(url, { signal });
19
- const player = new ReplayPlayer(recording);
19
+ const player = new Player(recording);
20
20
  const renderer = new ImageRenderer(canvas, { imageTopic: "head_left" });
21
21
  player.subscribe(renderer);
22
22
  await player.seek(recording.start);
@@ -28,34 +28,36 @@ by `logTime`; unordered input fails. Do not await `recording.loaded` before play
28
28
  Observe loading and late errors with `recording.subscribe`; call `player.close()`,
29
29
  `recording.close()` and `renderer.destroy()` when unmounting.
30
30
 
31
- The core plays `eventual.replay.v1.CompressedVideo`, the only schema the service
31
+ The core plays `eventual.mda.v1.CompressedVideo`, the only schema the service
32
32
  serves: native H.264/HEVC packets with their decode order, in ZSTD-compressed MCAP
33
33
  with embedded Protobuf schemas. Times remain `bigint` nanoseconds.
34
34
 
35
35
  ```ts
36
- import { ReplayDataLoader } from "@eventual-ai/mcap-viewer/data-loader";
36
+ import { DataLoader } from "@eventual-ai/mcap-viewer/data-loader";
37
37
 
38
- const loader = new ReplayDataLoader({
39
- baseUrl: REPLAY_URL,
38
+ const loader = new DataLoader({
39
+ baseUrl: MDA_URL,
40
40
  headers: { Authorization: `Bearer ${token}` },
41
41
  });
42
42
  const topics = await loader.discover(datasource);
43
- const [span] = await loader.manifest({
43
+ const [result] = await loader.manifest({
44
44
  datasource,
45
45
  topics: ["head_left"],
46
46
  spans: [{ id: episodeId, start: 10, end: 20 }],
47
47
  });
48
- const recording = await loader.openSpan(span, { signal });
48
+ const recording = await loader.openSpan(result, { signal });
49
49
  ```
50
50
 
51
51
  Each manifest supplies one streamed MCAP URL. Payload requests omit control
52
- credentials. The loader applies the requested span bounds. `discover` maps topic
52
+ credentials. Playback covers the requested span, which the MCAP records in its
53
+ `mda` metadata, so `McapRecording.open` on a saved file starts at the same time
54
+ (`bounds: null` keeps the keyframe preroll). `discover` maps topic
53
55
  names to canonical schemas and actual wire encodings:
54
56
 
55
57
  ```json
56
58
  {
57
59
  "head_left": {
58
- "schema": "eventual.replay.v1.CompressedVideo",
60
+ "schema": "eventual.mda.v1.CompressedVideo",
59
61
  "schema_encoding": "protobuf",
60
62
  "message_encoding": "protobuf"
61
63
  }
@@ -67,5 +69,4 @@ names to canonical schemas and actual wire encodings:
67
69
  Video requires WebCodecs over HTTPS or localhost; available codecs depend on the
68
70
  browser and device. Unsupported codecs report an error through `renderer.onError`.
69
71
 
70
- The package bundles its dependencies; their licenses are in
71
- `dist/THIRD_PARTY_LICENSES.md`. MIT licensed.
72
+ MIT licensed.
@@ -1,6 +1,6 @@
1
1
  export { BlobSource, HttpSource, McapRecording } from "./mcap/index.js";
2
- export type { McapStreamSource, LoadOptions, ReadOptions, Recording, RecordingLoadState, ReplayMessage, TimeBounds, Topic, } from "./mcap/index.js";
3
- export { ReplayPlayer } from "./playback/index.js";
2
+ export type { McapStreamSource, LoadOptions, ReadOptions, Recording, RecordingLoadState, Message, TimeBounds, Topic, } from "./mcap/index.js";
3
+ export { Player } from "./playback/index.js";
4
4
  export type { PlaybackListener, PlaybackState, Scheduler, SubscriptionOptions, } from "./playback/index.js";
5
5
  export { ImageRenderer } from "./renderers/index.js";
6
6
  export type { ImageRendererOptions } from "./renderers/index.js";
@@ -0,0 +1,3 @@
1
+ export { BlobSource, HttpSource, McapRecording } from "./mcap/index.js";
2
+ export { Player } from "./playback/index.js";
3
+ export { ImageRenderer } from "./renderers/index.js";
@@ -0,0 +1,22 @@
1
+ export function inspectAccessUnit(bytes) {
2
+ let keyframe = false;
3
+ let codec;
4
+ for (let index = 0; index + 3 < bytes.length; index++) {
5
+ if (bytes[index] !== 0 || bytes[index + 1] !== 0)
6
+ continue;
7
+ let header = -1;
8
+ if (bytes[index + 2] === 1)
9
+ header = index + 3;
10
+ else if (bytes[index + 2] === 0 && bytes[index + 3] === 1)
11
+ header = index + 4;
12
+ if (header < 0 || header >= bytes.length)
13
+ continue;
14
+ const kind = bytes[header] & 31;
15
+ if (kind === 5)
16
+ keyframe = true;
17
+ if (kind === 7 && header + 3 < bytes.length)
18
+ codec = `avc1.${Array.from(bytes.slice(header + 1, header + 4), (value) => value.toString(16).padStart(2, "0")).join("")}`;
19
+ index = header;
20
+ }
21
+ return { keyframe, codec };
22
+ }
@@ -1,5 +1,6 @@
1
- export interface VideoAccessUnit {
1
+ interface VideoAccessUnit {
2
2
  keyframe: boolean;
3
3
  codec?: string;
4
4
  }
5
5
  export declare function inspectVideo(bytes: Uint8Array, format: string): VideoAccessUnit;
6
+ export {};
@@ -0,0 +1,64 @@
1
+ import { inspectAccessUnit } from "./h264.js";
2
+ export function inspectVideo(bytes, format) {
3
+ if (format === "h264")
4
+ return inspectAccessUnit(bytes);
5
+ if (format !== "h265")
6
+ throw new Error(`Unsupported video format: ${format}`);
7
+ let keyframe = false;
8
+ let codec;
9
+ const units = [];
10
+ let begin = -1;
11
+ for (let index = 0; index + 3 < bytes.length; index++) {
12
+ if (bytes[index] !== 0 || bytes[index + 1] !== 0)
13
+ continue;
14
+ const length = bytes[index + 2] === 1
15
+ ? 3
16
+ : bytes[index + 2] === 0 && bytes[index + 3] === 1
17
+ ? 4
18
+ : 0;
19
+ if (!length)
20
+ continue;
21
+ if (begin >= 0)
22
+ units.push(bytes.subarray(begin, index));
23
+ begin = index + length;
24
+ index = begin - 1;
25
+ }
26
+ if (begin >= 0)
27
+ units.push(bytes.subarray(begin));
28
+ const parameterSets = new Set();
29
+ for (const unit of units) {
30
+ const kind = (unit[0] >> 1) & 63;
31
+ if (kind >= 16 && kind <= 21)
32
+ keyframe = true;
33
+ if (kind >= 32 && kind <= 34)
34
+ parameterSets.add(kind);
35
+ if (kind !== 33)
36
+ continue;
37
+ const rbsp = [];
38
+ for (let index = 2; index < unit.length; index++) {
39
+ if (index >= 4 &&
40
+ unit[index] === 3 &&
41
+ unit[index - 1] === 0 &&
42
+ unit[index - 2] === 0)
43
+ continue;
44
+ rbsp.push(unit[index]);
45
+ }
46
+ if (rbsp.length < 13)
47
+ continue;
48
+ const profile = rbsp[1];
49
+ let compatibility = 0;
50
+ for (let bit = 0; bit < 32; bit++)
51
+ compatibility +=
52
+ ((rbsp[2 + (bit >> 3)] >> (7 - (bit & 7))) & 1) * 2 ** bit;
53
+ const constraints = rbsp.slice(6, 12);
54
+ while (constraints.length && constraints.at(-1) === 0)
55
+ constraints.pop();
56
+ const suffix = constraints
57
+ .map((value) => value.toString(16).toUpperCase().padStart(2, "0"))
58
+ .join(".");
59
+ codec = `hvc1.${["", "A", "B", "C"][profile >> 6]}${profile & 31}.${compatibility.toString(16).toUpperCase()}.${profile & 32 ? "H" : "L"}${rbsp[12]}${suffix ? `.${suffix}` : ""}`;
60
+ }
61
+ if (parameterSets.size !== 3)
62
+ codec = undefined;
63
+ return { keyframe, codec };
64
+ }
@@ -2,4 +2,4 @@ export { McapRecording } from "./recording.js";
2
2
  export { BlobSource, HttpSource } from "./source.js";
3
3
  export type { McapStreamSource, LoadOptions } from "./source.js";
4
4
  export type { ReadOptions } from "./reader.js";
5
- export type { Recording, RecordingLoadState, ReplayMessage, TimeBounds, Topic, } from "./types.js";
5
+ export type { Recording, RecordingLoadState, Message, TimeBounds, Topic, } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export { McapRecording } from "./recording.js";
2
+ export { BlobSource, HttpSource } from "./source.js";
@@ -0,0 +1,50 @@
1
+ export class RecordingLoad {
2
+ bufferedEnd;
3
+ error;
4
+ complete = false;
5
+ loaded;
6
+ controller = new AbortController();
7
+ resolve;
8
+ reject;
9
+ listeners = new Set();
10
+ constructor() {
11
+ this.loaded = new Promise((resolve, reject) => {
12
+ this.resolve = resolve;
13
+ this.reject = reject;
14
+ });
15
+ void this.loaded.catch(() => { });
16
+ }
17
+ subscribe(listener) {
18
+ this.listeners.add(listener);
19
+ return () => {
20
+ this.listeners.delete(listener);
21
+ };
22
+ }
23
+ advance(time) {
24
+ this.bufferedEnd = time;
25
+ this.emit();
26
+ }
27
+ finish() {
28
+ if (this.error)
29
+ return;
30
+ this.complete = true;
31
+ this.resolve();
32
+ this.emit();
33
+ }
34
+ fail(error) {
35
+ if (this.complete || this.error)
36
+ return;
37
+ this.error = error instanceof Error ? error : new Error(String(error));
38
+ this.reject(this.error);
39
+ this.emit();
40
+ }
41
+ close() {
42
+ if (this.complete || this.error)
43
+ return;
44
+ this.controller.abort(new DOMException("MCAP recording closed", "AbortError"));
45
+ }
46
+ emit() {
47
+ for (const listener of this.listeners)
48
+ listener();
49
+ }
50
+ }
@@ -3,6 +3,11 @@ import type { LoadOptions, McapStreamSource } from "./source.js";
3
3
  import { McapRecording } from "./recording.js";
4
4
  import type { TimeBounds } from "./types.js";
5
5
  export interface ReadOptions extends LoadOptions {
6
- bounds?: TimeBounds;
6
+ /**
7
+ * Playback bounds. By default, the span MDA records in `mda` metadata
8
+ * (`start_ns`, exclusive `end_ns`), whose file starts at the preceding
9
+ * keyframe; `null` plays every message.
10
+ */
11
+ bounds?: TimeBounds | null;
7
12
  }
8
13
  export declare function readMcap(input: Blob | McapStreamSource, options?: ReadOptions): Promise<McapRecording>;
@@ -0,0 +1,141 @@
1
+ import { McapStreamReader } from "@mcap/core";
2
+ import protobuf from "protobufjs";
3
+ import "protobufjs/ext/descriptor/index.js";
4
+ import { ZSTDDecoder } from "zstddec";
5
+ import { BlobSource } from "./source.js";
6
+ import { RecordingLoad } from "./loading.js";
7
+ import { McapRecording } from "./recording.js";
8
+ const zstd = new ZSTDDecoder();
9
+ /** Decode messages with their embedded Protobuf schema; other encodings stay raw bytes. */
10
+ function decoder(channel, schema) {
11
+ if (channel.messageEncoding !== "protobuf" || schema?.encoding !== "protobuf")
12
+ return (bytes) => bytes;
13
+ const root = protobuf.Root;
14
+ const type = root.fromDescriptor(schema.data).lookupType(schema.name);
15
+ return (bytes) => type.toObject(type.decode(bytes), {
16
+ defaults: true,
17
+ longs: String,
18
+ bytes: Uint8Array,
19
+ });
20
+ }
21
+ export async function readMcap(input, options = {}) {
22
+ options.signal?.throwIfAborted();
23
+ await zstd.init();
24
+ options.signal?.throwIfAborted();
25
+ const loading = new RecordingLoad();
26
+ const signal = options.signal
27
+ ? AbortSignal.any([options.signal, loading.controller.signal])
28
+ : loading.controller.signal;
29
+ const source = input instanceof Blob ? new BlobSource(input) : input;
30
+ const reader = new McapStreamReader({
31
+ validateCrcs: true,
32
+ decompressHandlers: {
33
+ zstd: (bytes, size) => zstd.decode(bytes, Number(size)),
34
+ },
35
+ });
36
+ const schemas = new Map();
37
+ const channels = new Map();
38
+ const decoders = new Map();
39
+ const byTopic = new Map();
40
+ const messages = [];
41
+ const metadata = new Map();
42
+ let recording = new McapRecording(metadata, channels, messages, byTopic, loading, options.bounds ?? undefined);
43
+ let opened = false;
44
+ let resolveReady;
45
+ let rejectReady;
46
+ const ready = new Promise((resolve, reject) => {
47
+ resolveReady = resolve;
48
+ rejectReady = reject;
49
+ });
50
+ const open = () => {
51
+ opened = true;
52
+ resolveReady(recording);
53
+ };
54
+ // MDA writes its metadata before any message, so bounds are set before `open`.
55
+ const applyMdaBounds = (record) => {
56
+ if (options.bounds !== undefined || opened)
57
+ return;
58
+ const start = record.get("start_ns");
59
+ const end = record.get("end_ns");
60
+ if (start !== undefined && end !== undefined)
61
+ recording = recording.withBounds({
62
+ start: BigInt(start),
63
+ end: BigInt(end) - 1n,
64
+ });
65
+ };
66
+ async function consume() {
67
+ let lastTime;
68
+ for await (const bytes of source.stream({ signal })) {
69
+ signal.throwIfAborted();
70
+ if (!bytes.length)
71
+ continue;
72
+ reader.append(bytes);
73
+ let bufferedEnd = loading.bufferedEnd;
74
+ for (let record = reader.nextRecord(); record; record = reader.nextRecord()) {
75
+ signal.throwIfAborted();
76
+ if (record.type === "Schema")
77
+ schemas.set(record.id, record);
78
+ else if (record.type === "Metadata") {
79
+ metadata.set(record.name, record.metadata);
80
+ if (record.name === "mda")
81
+ applyMdaBounds(record.metadata);
82
+ }
83
+ else if (record.type === "Channel") {
84
+ // The summary section repeats channels; keep the objects messages reference.
85
+ if (channels.has(record.id))
86
+ continue;
87
+ const schema = schemas.get(record.schemaId);
88
+ const channel = {
89
+ id: record.id,
90
+ topic: record.topic,
91
+ schemaName: schema?.name,
92
+ encoding: record.messageEncoding,
93
+ schemaEncoding: schema?.encoding,
94
+ schemaData: schema?.data,
95
+ metadata: record.metadata,
96
+ };
97
+ channels.set(record.id, channel);
98
+ decoders.set(record.id, decoder(record, schema));
99
+ if (!byTopic.has(channel.topic))
100
+ byTopic.set(channel.topic, []);
101
+ }
102
+ else if (record.type === "Message") {
103
+ if (lastTime !== undefined && record.logTime < lastTime)
104
+ throw new Error("MCAP messages must be ordered by logTime");
105
+ if (lastTime !== undefined && record.logTime > lastTime)
106
+ bufferedEnd = record.logTime - 1n;
107
+ lastTime = record.logTime;
108
+ const channel = channels.get(record.channelId);
109
+ const decode = decoders.get(record.channelId);
110
+ if (!channel || !decode)
111
+ throw new Error(`Missing MCAP channel ${record.channelId}`);
112
+ const message = {
113
+ channel,
114
+ time: record.logTime,
115
+ publishTime: record.publishTime,
116
+ sequence: record.sequence,
117
+ value: decode(record.data),
118
+ data: record.data,
119
+ };
120
+ messages.push(message);
121
+ byTopic.get(channel.topic).push(message);
122
+ }
123
+ }
124
+ if (bufferedEnd !== undefined && bufferedEnd !== loading.bufferedEnd) {
125
+ loading.advance(bufferedEnd);
126
+ if (recording.bufferedEnd !== undefined)
127
+ open();
128
+ }
129
+ }
130
+ signal.throwIfAborted();
131
+ if (!reader.done())
132
+ throw new Error("Incomplete MCAP recording");
133
+ loading.finish();
134
+ open();
135
+ }
136
+ void consume().catch((error) => {
137
+ loading.fail(error);
138
+ rejectReady(error);
139
+ });
140
+ return ready;
141
+ }
@@ -1,17 +1,17 @@
1
- import type { Recording, RecordingLoadState, ReplayMessage, TimeBounds, Topic } from "./types.js";
1
+ import type { Recording, RecordingLoadState, Message, TimeBounds, Topic } from "./types.js";
2
2
  import type { ReadOptions } from "./reader.js";
3
3
  import type { McapStreamSource } from "./source.js";
4
4
  import type { RecordingLoad } from "./loading.js";
5
5
  export declare class McapRecording implements Recording {
6
6
  readonly metadata: ReadonlyMap<string, ReadonlyMap<string, string>>;
7
7
  readonly channels: ReadonlyMap<number, Topic>;
8
- readonly messages: readonly ReplayMessage[];
9
- readonly byTopic: ReadonlyMap<string, readonly ReplayMessage[]>;
8
+ readonly messages: readonly Message[];
9
+ readonly byTopic: ReadonlyMap<string, readonly Message[]>;
10
10
  private readonly loading;
11
11
  readonly bounds?: TimeBounds | undefined;
12
12
  /** Stream an MCAP file, URL, or byte source independently of a backend. */
13
13
  static open(input: Blob | McapStreamSource | string | URL, options?: ReadOptions): Promise<McapRecording>;
14
- constructor(metadata: ReadonlyMap<string, ReadonlyMap<string, string>>, channels: ReadonlyMap<number, Topic>, messages: readonly ReplayMessage[], byTopic: ReadonlyMap<string, readonly ReplayMessage[]>, loading: RecordingLoad, bounds?: TimeBounds | undefined);
14
+ constructor(metadata: ReadonlyMap<string, ReadonlyMap<string, string>>, channels: ReadonlyMap<number, Topic>, messages: readonly Message[], byTopic: ReadonlyMap<string, readonly Message[]>, loading: RecordingLoad, bounds?: TimeBounds | undefined);
15
15
  get start(): bigint;
16
16
  get end(): bigint;
17
17
  get counts(): Readonly<Record<string, number>>;
@@ -21,7 +21,10 @@ export declare class McapRecording implements Recording {
21
21
  get loaded(): Promise<void>;
22
22
  /** Observe buffered playback coverage, completion, or a loading error. */
23
23
  subscribe(listener: (state: RecordingLoadState) => void): () => void;
24
- /** Cancel the shared source load for this recording and all bounded views. */
24
+ /**
25
+ * Cancel the shared source load for this recording and all bounded views.
26
+ * Players attached to it stop without reporting an error.
27
+ */
25
28
  close(): void;
26
29
  /** Select playback bounds without removing samples needed for seeking. */
27
30
  withBounds(bounds: TimeBounds): McapRecording;
@@ -0,0 +1,72 @@
1
+ import { readMcap } from "./reader.js";
2
+ import { HttpSource } from "./source.js";
3
+ export class McapRecording {
4
+ metadata;
5
+ channels;
6
+ messages;
7
+ byTopic;
8
+ loading;
9
+ bounds;
10
+ /** Stream an MCAP file, URL, or byte source independently of a backend. */
11
+ static async open(input, options = {}) {
12
+ const source = typeof input === "string" || input instanceof URL
13
+ ? new HttpSource(input)
14
+ : input;
15
+ return readMcap(source, options);
16
+ }
17
+ constructor(metadata, channels, messages, byTopic, loading, bounds) {
18
+ this.metadata = metadata;
19
+ this.channels = channels;
20
+ this.messages = messages;
21
+ this.byTopic = byTopic;
22
+ this.loading = loading;
23
+ this.bounds = bounds;
24
+ if (this.start > this.end)
25
+ throw new Error("Recording end must not precede its start");
26
+ }
27
+ get start() {
28
+ return this.bounds?.start ?? this.messages[0]?.time ?? 0n;
29
+ }
30
+ get end() {
31
+ return this.bounds?.end ?? this.messages.at(-1)?.time ?? this.start;
32
+ }
33
+ get counts() {
34
+ return Object.fromEntries([...this.byTopic].map(([topic, records]) => [topic, records.length]));
35
+ }
36
+ get bufferedEnd() {
37
+ if (this.complete)
38
+ return this.end;
39
+ const time = this.loading.bufferedEnd;
40
+ if (time === undefined || time < this.start)
41
+ return undefined;
42
+ return time > this.end ? this.end : time;
43
+ }
44
+ get complete() {
45
+ return this.loading.complete;
46
+ }
47
+ get error() {
48
+ return this.loading.error;
49
+ }
50
+ get loaded() {
51
+ return this.loading.loaded;
52
+ }
53
+ /** Observe buffered playback coverage, completion, or a loading error. */
54
+ subscribe(listener) {
55
+ return this.loading.subscribe(() => listener({
56
+ bufferedEnd: this.bufferedEnd,
57
+ complete: this.complete,
58
+ error: this.error,
59
+ }));
60
+ }
61
+ /**
62
+ * Cancel the shared source load for this recording and all bounded views.
63
+ * Players attached to it stop without reporting an error.
64
+ */
65
+ close() {
66
+ this.loading.close();
67
+ }
68
+ /** Select playback bounds without removing samples needed for seeking. */
69
+ withBounds(bounds) {
70
+ return new McapRecording(this.metadata, this.channels, this.messages, this.byTopic, this.loading, bounds);
71
+ }
72
+ }
@@ -0,0 +1,67 @@
1
+ async function* streamBytes(stream, signal) {
2
+ const reader = stream.getReader();
3
+ const abort = () => void reader.cancel(signal?.reason).catch(() => { });
4
+ signal?.addEventListener("abort", abort, { once: true });
5
+ try {
6
+ if (signal?.aborted)
7
+ abort();
8
+ while (true) {
9
+ signal?.throwIfAborted();
10
+ const result = await reader.read();
11
+ signal?.throwIfAborted();
12
+ if (result.done)
13
+ return;
14
+ yield result.value;
15
+ }
16
+ }
17
+ finally {
18
+ signal?.removeEventListener("abort", abort);
19
+ await reader.cancel().catch(() => { });
20
+ reader.releaseLock();
21
+ }
22
+ }
23
+ /** A local Blob or File, read incrementally. */
24
+ export class BlobSource {
25
+ blob;
26
+ constructor(blob) {
27
+ this.blob = blob;
28
+ }
29
+ stream({ signal } = {}) {
30
+ return streamBytes(this.blob.stream(), signal);
31
+ }
32
+ }
33
+ /** Download a signed MCAP URL without forwarding control API credentials. */
34
+ export class HttpSource {
35
+ url;
36
+ options;
37
+ constructor(url, options = {}) {
38
+ this.url = url;
39
+ this.options = options;
40
+ }
41
+ combinedSignal(signal) {
42
+ const constructorSignal = this.options.signal;
43
+ return constructorSignal && signal
44
+ ? AbortSignal.any([constructorSignal, signal])
45
+ : (constructorSignal ?? signal);
46
+ }
47
+ /** Stream the response body directly; no sizing request or full download is needed. */
48
+ async *stream({ signal } = {}) {
49
+ const combinedSignal = this.combinedSignal(signal);
50
+ combinedSignal?.throwIfAborted();
51
+ const response = await this.request(combinedSignal);
52
+ if (!response.body)
53
+ throw new Error("MCAP response has no body");
54
+ yield* streamBytes(response.body, combinedSignal);
55
+ }
56
+ async request(signal) {
57
+ signal?.throwIfAborted();
58
+ const response = await fetch(this.url, {
59
+ signal,
60
+ cache: "no-store",
61
+ credentials: "omit",
62
+ });
63
+ if (!response.ok)
64
+ throw new Error(`MCAP request failed (${response.status}): ${await response.text()}`);
65
+ return response;
66
+ }
67
+ }
@@ -7,7 +7,7 @@ export interface Topic {
7
7
  readonly schemaData?: Uint8Array;
8
8
  readonly metadata: ReadonlyMap<string, string>;
9
9
  }
10
- export interface ReplayMessage<T = unknown> {
10
+ export interface Message<T = unknown> {
11
11
  readonly channel: Topic;
12
12
  readonly time: bigint;
13
13
  readonly publishTime: bigint;
@@ -31,6 +31,6 @@ export interface Recording extends TimeBounds {
31
31
  readonly error?: Error;
32
32
  subscribe?(listener: (state: RecordingLoadState) => void): () => void;
33
33
  readonly channels: ReadonlyMap<number, Topic>;
34
- readonly messages: readonly ReplayMessage[];
35
- readonly byTopic: ReadonlyMap<string, readonly ReplayMessage[]>;
34
+ readonly messages: readonly Message[];
35
+ readonly byTopic: ReadonlyMap<string, readonly Message[]>;
36
36
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,2 @@
1
- export { ReplayPlayer } from "./player.js";
1
+ export { Player } from "./player.js";
2
2
  export type { PlaybackListener, PlaybackState, Scheduler, SubscriptionOptions, } from "./player.js";
@@ -0,0 +1 @@
1
+ export { Player } from "./player.js";
@@ -21,7 +21,7 @@ export interface SubscriptionOptions {
21
21
  topics?: readonly string[];
22
22
  }
23
23
  /** Local playback clock for a recording's native-timestamped video topics. */
24
- export declare class ReplayPlayer {
24
+ export declare class Player {
25
25
  readonly recording: Recording;
26
26
  private readonly scheduler;
27
27
  time: bigint;
@@ -34,14 +34,20 @@ export declare class ReplayPlayer {
34
34
  private anchorTime;
35
35
  private anchorWall;
36
36
  private waitingForData;
37
+ private started;
37
38
  private seekController?;
38
39
  private readonly video;
39
40
  private readonly submitted;
40
41
  private readonly unsubscribeRecording?;
41
42
  private readonly listeners;
42
43
  constructor(recording: Recording, callbacks?: PlaybackListener, scheduler?: Scheduler);
43
- /** Subscribe a view or renderer to the shared playback clock and selected topics. */
44
+ /**
45
+ * Subscribe a view or renderer to the shared playback clock and selected topics.
46
+ * It receives the current state, and once playback has started, a snapshot of
47
+ * the current frame. Call the returned function to unsubscribe.
48
+ */
44
49
  subscribe(listener: PlaybackListener, { topics }?: SubscriptionOptions): () => void;
50
+ private state;
45
51
  private emit;
46
52
  private publish;
47
53
  private cancelFrame;