@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.
- package/README.md +15 -14
- package/dist/{types/core → core}/index.d.ts +2 -2
- package/dist/core/index.js +3 -0
- package/dist/core/mcap/codecs/h264.js +22 -0
- package/dist/{types/core → core}/mcap/codecs/video.d.ts +2 -1
- package/dist/core/mcap/codecs/video.js +64 -0
- package/dist/{types/core → core}/mcap/index.d.ts +1 -1
- package/dist/core/mcap/index.js +2 -0
- package/dist/core/mcap/loading.js +50 -0
- package/dist/{types/core → core}/mcap/reader.d.ts +6 -1
- package/dist/core/mcap/reader.js +141 -0
- package/dist/{types/core → core}/mcap/recording.d.ts +8 -5
- package/dist/core/mcap/recording.js +72 -0
- package/dist/core/mcap/source.js +67 -0
- package/dist/{types/core → core}/mcap/types.d.ts +3 -3
- package/dist/core/mcap/types.js +1 -0
- package/dist/{types/core → core}/playback/index.d.ts +1 -1
- package/dist/core/playback/index.js +1 -0
- package/dist/{types/core → core}/playback/player.d.ts +8 -2
- package/dist/core/playback/player.js +344 -0
- package/dist/{types/core → core}/playback/snapshot.d.ts +2 -2
- package/dist/core/playback/snapshot.js +11 -0
- package/dist/{types/core → core}/playback/video-sequence.d.ts +7 -7
- package/dist/core/playback/video-sequence.js +116 -0
- package/dist/{types/core → core}/renderers/image.d.ts +2 -0
- package/dist/core/renderers/image.js +165 -0
- package/dist/core/renderers/index.js +1 -0
- package/dist/{types/core → core}/renderers/video-decoder.d.ts +4 -1
- package/dist/core/renderers/video-decoder.js +146 -0
- package/dist/core/schemas.d.ts +19 -0
- package/dist/core/schemas.js +13 -0
- package/dist/data-loader/index.d.ts +2 -0
- package/dist/data-loader/index.js +1 -0
- package/dist/{types/data-loader → data-loader}/loader.d.ts +4 -4
- package/dist/data-loader/loader.js +44 -0
- package/dist/{types/data-loader → data-loader}/types.d.ts +3 -3
- package/dist/data-loader/types.js +1 -0
- package/dist/index.js +2 -0
- package/package.json +17 -16
- package/dist/THIRD_PARTY_LICENSES.md +0 -415
- package/dist/core.js +0 -534
- package/dist/data-loader.js +0 -53
- package/dist/mcap-viewer.js +0 -11
- package/dist/recording-tVYbg6KM.js +0 -9109
- package/dist/types/core/schemas.d.ts +0 -15
- package/dist/types/data-loader/index.d.ts +0 -2
- /package/dist/{types/core → core}/mcap/codecs/h264.d.ts +0 -0
- /package/dist/{types/core → core}/mcap/loading.d.ts +0 -0
- /package/dist/{types/core → core}/mcap/source.d.ts +0 -0
- /package/dist/{types/core → core}/renderers/index.d.ts +0 -0
- /package/dist/{types/index.d.ts → index.d.ts} +0 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { snapshot } from "./snapshot.js";
|
|
2
|
+
import { VideoSequence, videoSequence, decodeOrder, isVideo, } from "./video-sequence.js";
|
|
3
|
+
const defaultScheduler = {
|
|
4
|
+
now: () => performance.now(),
|
|
5
|
+
request: (callback) => requestAnimationFrame(callback),
|
|
6
|
+
cancel: (id) => cancelAnimationFrame(id),
|
|
7
|
+
};
|
|
8
|
+
/** Local playback clock for a recording's native-timestamped video topics. */
|
|
9
|
+
export class Player {
|
|
10
|
+
recording;
|
|
11
|
+
scheduler;
|
|
12
|
+
time;
|
|
13
|
+
speed = 1;
|
|
14
|
+
playing = false;
|
|
15
|
+
status = "paused";
|
|
16
|
+
closed = false;
|
|
17
|
+
generation = 0;
|
|
18
|
+
frame;
|
|
19
|
+
anchorTime = 0n;
|
|
20
|
+
anchorWall = 0;
|
|
21
|
+
waitingForData = false;
|
|
22
|
+
started = false;
|
|
23
|
+
seekController;
|
|
24
|
+
video = new Map();
|
|
25
|
+
submitted = new Map();
|
|
26
|
+
unsubscribeRecording;
|
|
27
|
+
listeners = new Set();
|
|
28
|
+
constructor(recording, callbacks = {}, scheduler = defaultScheduler) {
|
|
29
|
+
this.recording = recording;
|
|
30
|
+
this.scheduler = scheduler;
|
|
31
|
+
this.time = recording.start;
|
|
32
|
+
this.listeners.add({ listener: callbacks });
|
|
33
|
+
this.unsubscribeRecording = recording.subscribe?.(() => this.onLoad());
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe a view or renderer to the shared playback clock and selected topics.
|
|
37
|
+
* It receives the current state, and once playback has started, a snapshot of
|
|
38
|
+
* the current frame. Call the returned function to unsubscribe.
|
|
39
|
+
*/
|
|
40
|
+
subscribe(listener, { topics } = {}) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
throw new Error("Player is closed");
|
|
43
|
+
const subscription = {
|
|
44
|
+
listener,
|
|
45
|
+
topics: topics ? new Set(topics) : undefined,
|
|
46
|
+
};
|
|
47
|
+
this.listeners.add(subscription);
|
|
48
|
+
const unsubscribe = () => {
|
|
49
|
+
this.listeners.delete(subscription);
|
|
50
|
+
};
|
|
51
|
+
listener.onState?.(this.state());
|
|
52
|
+
if (this.started &&
|
|
53
|
+
this.status !== "buffering" &&
|
|
54
|
+
this.status !== "error") {
|
|
55
|
+
listener.onSnapshotStart?.();
|
|
56
|
+
try {
|
|
57
|
+
for (const record of snapshot(this.recording, this.time))
|
|
58
|
+
if (!subscription.topics ||
|
|
59
|
+
subscription.topics.has(record.channel.topic))
|
|
60
|
+
listener.onMessage?.(record.channel, record.time, record.value);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.fail(error);
|
|
64
|
+
return unsubscribe;
|
|
65
|
+
}
|
|
66
|
+
void listener.onSnapshotEnd?.(!this.playing);
|
|
67
|
+
}
|
|
68
|
+
return unsubscribe;
|
|
69
|
+
}
|
|
70
|
+
state() {
|
|
71
|
+
return {
|
|
72
|
+
time: this.time,
|
|
73
|
+
status: this.status,
|
|
74
|
+
playing: this.playing,
|
|
75
|
+
speed: this.speed,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
emit() {
|
|
79
|
+
const state = this.state();
|
|
80
|
+
for (const { listener } of this.listeners)
|
|
81
|
+
listener.onState?.(state);
|
|
82
|
+
}
|
|
83
|
+
publish(record) {
|
|
84
|
+
this.submitted.set(record.channel.topic, decodeOrder(record));
|
|
85
|
+
for (const { listener, topics } of this.listeners) {
|
|
86
|
+
if (this.closed)
|
|
87
|
+
return;
|
|
88
|
+
if (!topics || topics.has(record.channel.topic))
|
|
89
|
+
listener.onMessage?.(record.channel, record.time, record.value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
cancelFrame() {
|
|
93
|
+
if (this.frame !== undefined)
|
|
94
|
+
this.scheduler.cancel(this.frame);
|
|
95
|
+
this.frame = undefined;
|
|
96
|
+
}
|
|
97
|
+
fail(error) {
|
|
98
|
+
// A recording closed or aborted by its owner ends playback, not as an error.
|
|
99
|
+
if (error === this.recording.error &&
|
|
100
|
+
error instanceof Error &&
|
|
101
|
+
error.name === "AbortError") {
|
|
102
|
+
this.playing = false;
|
|
103
|
+
this.status = "paused";
|
|
104
|
+
this.emit();
|
|
105
|
+
this.close();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.generation++;
|
|
109
|
+
this.seekController?.abort();
|
|
110
|
+
this.waitingForData = false;
|
|
111
|
+
this.playing = false;
|
|
112
|
+
this.status = "error";
|
|
113
|
+
this.cancelFrame();
|
|
114
|
+
for (const { listener } of this.listeners)
|
|
115
|
+
listener.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
116
|
+
this.emit();
|
|
117
|
+
}
|
|
118
|
+
availableEnd() {
|
|
119
|
+
for (const records of this.recording.byTopic.values()) {
|
|
120
|
+
const channel = records[0]?.channel;
|
|
121
|
+
if (channel && isVideo(channel) && !this.video.has(channel.topic))
|
|
122
|
+
this.video.set(channel.topic, videoSequence(channel, records));
|
|
123
|
+
}
|
|
124
|
+
let end = this.recording.complete === false
|
|
125
|
+
? (this.recording.bufferedEnd ?? this.recording.start - 1n)
|
|
126
|
+
: this.recording.end;
|
|
127
|
+
for (const sequence of this.video.values())
|
|
128
|
+
end = sequence.bufferedEnd(end, this.recording.complete !== false);
|
|
129
|
+
return end;
|
|
130
|
+
}
|
|
131
|
+
endBuffered() {
|
|
132
|
+
return (this.recording.complete !== false ||
|
|
133
|
+
(this.recording.bounds !== undefined &&
|
|
134
|
+
this.availableEnd() >= this.recording.end));
|
|
135
|
+
}
|
|
136
|
+
onLoad() {
|
|
137
|
+
// After a failure, only a new seek resumes reporting.
|
|
138
|
+
if (this.closed || this.status === "error")
|
|
139
|
+
return;
|
|
140
|
+
if (this.recording.error) {
|
|
141
|
+
this.fail(this.recording.error);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let bufferedEnd;
|
|
145
|
+
try {
|
|
146
|
+
bufferedEnd = this.availableEnd();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.fail(error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.waitingForData &&
|
|
153
|
+
this.playing &&
|
|
154
|
+
(this.recording.complete !== false || bufferedEnd > this.time)) {
|
|
155
|
+
this.waitingForData = false;
|
|
156
|
+
this.anchorTime = this.time;
|
|
157
|
+
this.anchorWall = this.scheduler.now();
|
|
158
|
+
this.status = "playing";
|
|
159
|
+
this.frame = this.scheduler.request(() => void this.tick());
|
|
160
|
+
}
|
|
161
|
+
this.emit();
|
|
162
|
+
}
|
|
163
|
+
waitForTime(time, signal) {
|
|
164
|
+
if (this.recording.error)
|
|
165
|
+
return Promise.reject(this.recording.error);
|
|
166
|
+
if (this.availableEnd() >= time || this.recording.complete !== false)
|
|
167
|
+
return Promise.resolve();
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
unsubscribe?.();
|
|
171
|
+
signal.removeEventListener("abort", aborted);
|
|
172
|
+
};
|
|
173
|
+
const check = () => {
|
|
174
|
+
if (this.recording.error) {
|
|
175
|
+
cleanup();
|
|
176
|
+
reject(this.recording.error);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
try {
|
|
180
|
+
if (this.availableEnd() >= time ||
|
|
181
|
+
this.recording.complete !== false) {
|
|
182
|
+
cleanup();
|
|
183
|
+
resolve();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
cleanup();
|
|
188
|
+
reject(error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const aborted = () => {
|
|
193
|
+
cleanup();
|
|
194
|
+
reject(signal.reason);
|
|
195
|
+
};
|
|
196
|
+
const unsubscribe = this.recording.subscribe?.(check);
|
|
197
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
aborted();
|
|
200
|
+
else
|
|
201
|
+
check();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async endSnapshot(paused) {
|
|
205
|
+
await Promise.all([...this.listeners].map(({ listener }) => listener.onSnapshotEnd?.(paused)));
|
|
206
|
+
}
|
|
207
|
+
async seek(target) {
|
|
208
|
+
if (this.closed)
|
|
209
|
+
return;
|
|
210
|
+
const generation = ++this.generation;
|
|
211
|
+
this.seekController?.abort();
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
this.seekController = controller;
|
|
214
|
+
this.waitingForData = false;
|
|
215
|
+
this.cancelFrame();
|
|
216
|
+
this.time =
|
|
217
|
+
target < this.recording.start
|
|
218
|
+
? this.recording.start
|
|
219
|
+
: target > this.recording.end &&
|
|
220
|
+
(this.recording.complete !== false ||
|
|
221
|
+
this.recording.bounds !== undefined)
|
|
222
|
+
? this.recording.end
|
|
223
|
+
: target;
|
|
224
|
+
this.status = "buffering";
|
|
225
|
+
this.emit();
|
|
226
|
+
try {
|
|
227
|
+
if (this.recording.error)
|
|
228
|
+
throw this.recording.error;
|
|
229
|
+
if (this.recording.complete === false && this.availableEnd() < this.time)
|
|
230
|
+
await this.waitForTime(this.time, controller.signal);
|
|
231
|
+
if (generation !== this.generation || this.closed)
|
|
232
|
+
return;
|
|
233
|
+
if (this.time > this.recording.end)
|
|
234
|
+
this.time = this.recording.end;
|
|
235
|
+
this.submitted.clear();
|
|
236
|
+
for (const { listener } of this.listeners)
|
|
237
|
+
listener.onSnapshotStart?.();
|
|
238
|
+
for (const record of snapshot(this.recording, this.time)) {
|
|
239
|
+
if (generation !== this.generation || this.closed)
|
|
240
|
+
return;
|
|
241
|
+
this.publish(record);
|
|
242
|
+
}
|
|
243
|
+
await this.endSnapshot(!this.playing);
|
|
244
|
+
if (generation !== this.generation || this.closed)
|
|
245
|
+
return;
|
|
246
|
+
this.started = true;
|
|
247
|
+
this.anchorTime = this.time;
|
|
248
|
+
this.anchorWall = this.scheduler.now();
|
|
249
|
+
this.status = this.playing ? "playing" : "paused";
|
|
250
|
+
this.emit();
|
|
251
|
+
if (this.playing)
|
|
252
|
+
this.frame = this.scheduler.request(() => void this.tick());
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (generation === this.generation && !this.closed)
|
|
256
|
+
this.fail(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
play() {
|
|
260
|
+
if (this.closed)
|
|
261
|
+
return;
|
|
262
|
+
this.playing = true;
|
|
263
|
+
let restart;
|
|
264
|
+
try {
|
|
265
|
+
restart = this.endBuffered() && this.time >= this.recording.end;
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
this.fail(error);
|
|
269
|
+
return Promise.resolve();
|
|
270
|
+
}
|
|
271
|
+
return this.seek(restart ? this.recording.start : this.time);
|
|
272
|
+
}
|
|
273
|
+
pause() {
|
|
274
|
+
if (this.closed)
|
|
275
|
+
return;
|
|
276
|
+
this.playing = false;
|
|
277
|
+
return this.seek(this.time);
|
|
278
|
+
}
|
|
279
|
+
setSpeed(speed) {
|
|
280
|
+
if (this.closed)
|
|
281
|
+
return;
|
|
282
|
+
if (!Number.isFinite(speed) || speed <= 0)
|
|
283
|
+
throw new Error("Playback speed must be positive");
|
|
284
|
+
this.speed = speed;
|
|
285
|
+
this.anchorTime = this.time;
|
|
286
|
+
this.anchorWall = this.scheduler.now();
|
|
287
|
+
this.emit();
|
|
288
|
+
}
|
|
289
|
+
async tick() {
|
|
290
|
+
this.frame = undefined;
|
|
291
|
+
if (this.closed || !this.playing)
|
|
292
|
+
return;
|
|
293
|
+
const generation = this.generation;
|
|
294
|
+
const target = this.anchorTime +
|
|
295
|
+
BigInt(Math.round((this.scheduler.now() - this.anchorWall) * 1e6 * this.speed));
|
|
296
|
+
try {
|
|
297
|
+
const bufferedEnd = this.availableEnd();
|
|
298
|
+
const availableEnd = bufferedEnd < this.recording.end ? bufferedEnd : this.recording.end;
|
|
299
|
+
this.time = target > availableEnd ? availableEnd : target;
|
|
300
|
+
// State first, so listeners see this tick's time with its packets.
|
|
301
|
+
this.emit();
|
|
302
|
+
for (const [topic, sequence] of this.video) {
|
|
303
|
+
const submitted = this.submitted.get(topic);
|
|
304
|
+
const records = submitted === undefined
|
|
305
|
+
? sequence.snapshot(this.time)
|
|
306
|
+
: sequence.after(this.time, submitted);
|
|
307
|
+
for (const record of records) {
|
|
308
|
+
this.publish(record);
|
|
309
|
+
if (generation !== this.generation || this.closed)
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (this.time === this.recording.end && this.endBuffered()) {
|
|
314
|
+
this.playing = false;
|
|
315
|
+
await this.endSnapshot(true);
|
|
316
|
+
if (generation !== this.generation || this.closed)
|
|
317
|
+
return;
|
|
318
|
+
this.status = "ended";
|
|
319
|
+
this.emit();
|
|
320
|
+
}
|
|
321
|
+
else if (this.recording.complete === false && target >= availableEnd) {
|
|
322
|
+
this.status = "buffering";
|
|
323
|
+
this.waitingForData = true;
|
|
324
|
+
this.emit();
|
|
325
|
+
}
|
|
326
|
+
if (this.playing && !this.waitingForData)
|
|
327
|
+
this.frame = this.scheduler.request(() => void this.tick());
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (generation === this.generation && !this.closed)
|
|
331
|
+
this.fail(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
close() {
|
|
335
|
+
this.closed = true;
|
|
336
|
+
this.playing = false;
|
|
337
|
+
this.generation++;
|
|
338
|
+
this.seekController?.abort();
|
|
339
|
+
this.unsubscribeRecording?.();
|
|
340
|
+
this.waitingForData = false;
|
|
341
|
+
this.cancelFrame();
|
|
342
|
+
this.listeners.clear();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { Recording,
|
|
1
|
+
import type { Recording, Message } from "../mcap/types.js";
|
|
2
2
|
/** The packets that rebuild each video topic's frame at `time`, in decode order. */
|
|
3
|
-
export declare function snapshot(recording: Recording, time: bigint): readonly
|
|
3
|
+
export declare function snapshot(recording: Recording, time: bigint): readonly Message[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isVideo, videoSequence } from "./video-sequence.js";
|
|
2
|
+
/** The packets that rebuild each video topic's frame at `time`, in decode order. */
|
|
3
|
+
export function snapshot(recording, time) {
|
|
4
|
+
const result = [];
|
|
5
|
+
for (const records of recording.byTopic.values()) {
|
|
6
|
+
const channel = records[0]?.channel;
|
|
7
|
+
if (channel && isVideo(channel))
|
|
8
|
+
result.push(...videoSequence(channel, records).snapshot(time));
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Message, Topic } from "../mcap/types.js";
|
|
2
2
|
export declare function isVideo(channel: Topic): boolean;
|
|
3
|
-
export declare function decodeOrder(record:
|
|
4
|
-
export declare function videoSequence(channel: Topic, records: readonly
|
|
3
|
+
export declare function decodeOrder(record: Message): bigint;
|
|
4
|
+
export declare function videoSequence(channel: Topic, records: readonly Message[]): VideoSequence;
|
|
5
5
|
export declare class VideoSequence {
|
|
6
6
|
readonly channel: Topic;
|
|
7
|
-
readonly records: readonly
|
|
7
|
+
readonly records: readonly Message[];
|
|
8
8
|
readonly start: bigint;
|
|
9
9
|
readonly end: bigint;
|
|
10
10
|
private readonly lookahead;
|
|
@@ -13,12 +13,12 @@ export declare class VideoSequence {
|
|
|
13
13
|
private count;
|
|
14
14
|
private safeCount;
|
|
15
15
|
private contiguous;
|
|
16
|
-
constructor(channel: Topic, records: readonly
|
|
16
|
+
constructor(channel: Topic, records: readonly Message[]);
|
|
17
17
|
private update;
|
|
18
18
|
private required;
|
|
19
19
|
private target;
|
|
20
20
|
bufferedEnd(time: bigint, complete: boolean): bigint;
|
|
21
|
-
snapshot(time: bigint): readonly
|
|
22
|
-
after(time: bigint, submitted: bigint): readonly
|
|
21
|
+
snapshot(time: bigint): readonly Message[];
|
|
22
|
+
after(time: bigint, submitted: bigint): readonly Message[];
|
|
23
23
|
private range;
|
|
24
24
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { compressedVideo, videoSchema } from "../schemas.js";
|
|
2
|
+
export function isVideo(channel) {
|
|
3
|
+
return channel.schemaName === videoSchema;
|
|
4
|
+
}
|
|
5
|
+
export function decodeOrder(record) {
|
|
6
|
+
const value = compressedVideo(record.value).decode_order;
|
|
7
|
+
if (typeof value !== "string" || !/^\d+$/.test(value))
|
|
8
|
+
throw new Error("Video packet is missing a valid decode_order");
|
|
9
|
+
return BigInt(value);
|
|
10
|
+
}
|
|
11
|
+
const sequences = new WeakMap();
|
|
12
|
+
export function videoSequence(channel, records) {
|
|
13
|
+
let sequence = sequences.get(records);
|
|
14
|
+
if (!sequence) {
|
|
15
|
+
sequence = new VideoSequence(channel, records);
|
|
16
|
+
sequences.set(records, sequence);
|
|
17
|
+
}
|
|
18
|
+
return sequence;
|
|
19
|
+
}
|
|
20
|
+
export class VideoSequence {
|
|
21
|
+
channel;
|
|
22
|
+
records;
|
|
23
|
+
start;
|
|
24
|
+
end;
|
|
25
|
+
lookahead;
|
|
26
|
+
packets = new Map();
|
|
27
|
+
keyframes = new Map();
|
|
28
|
+
count = 0;
|
|
29
|
+
safeCount = 0;
|
|
30
|
+
contiguous;
|
|
31
|
+
constructor(channel, records) {
|
|
32
|
+
this.channel = channel;
|
|
33
|
+
this.records = records;
|
|
34
|
+
const integer = (name) => {
|
|
35
|
+
const value = channel.metadata.get(name);
|
|
36
|
+
if (value === undefined || !/^\d+$/.test(value))
|
|
37
|
+
throw new Error(`Missing or invalid ${name} video metadata`);
|
|
38
|
+
return BigInt(value);
|
|
39
|
+
};
|
|
40
|
+
this.start = integer("video.decode_start");
|
|
41
|
+
this.end = integer("video.decode_end");
|
|
42
|
+
this.lookahead = integer("video.max_reorder_frames");
|
|
43
|
+
if (this.start > this.end)
|
|
44
|
+
throw new Error("Invalid video decode interval");
|
|
45
|
+
this.contiguous = this.start - 1n;
|
|
46
|
+
}
|
|
47
|
+
update() {
|
|
48
|
+
for (; this.count < this.records.length; this.count++) {
|
|
49
|
+
const record = this.records[this.count];
|
|
50
|
+
const order = decodeOrder(record);
|
|
51
|
+
if (order < this.start || order > this.end || this.packets.has(order))
|
|
52
|
+
throw new Error("Video decode_order is duplicated or outside the declared interval");
|
|
53
|
+
this.packets.set(order, record);
|
|
54
|
+
if (compressedVideo(record.value).keyframe)
|
|
55
|
+
this.keyframes.set(order, record);
|
|
56
|
+
}
|
|
57
|
+
while (this.packets.has(this.contiguous + 1n))
|
|
58
|
+
this.contiguous++;
|
|
59
|
+
}
|
|
60
|
+
required(record) {
|
|
61
|
+
const order = decodeOrder(record) + this.lookahead;
|
|
62
|
+
return order > this.end ? this.end : order;
|
|
63
|
+
}
|
|
64
|
+
target(time) {
|
|
65
|
+
let low = 0, high = this.records.length;
|
|
66
|
+
while (low < high) {
|
|
67
|
+
const middle = (low + high) >>> 1;
|
|
68
|
+
if (this.records[middle].time <= time)
|
|
69
|
+
low = middle + 1;
|
|
70
|
+
else
|
|
71
|
+
high = middle;
|
|
72
|
+
}
|
|
73
|
+
return this.records[low - 1];
|
|
74
|
+
}
|
|
75
|
+
bufferedEnd(time, complete) {
|
|
76
|
+
this.update();
|
|
77
|
+
if (complete && this.contiguous !== this.end)
|
|
78
|
+
throw new Error("Incomplete video decode interval");
|
|
79
|
+
if (complete)
|
|
80
|
+
return time;
|
|
81
|
+
while (this.safeCount < this.records.length &&
|
|
82
|
+
this.required(this.records[this.safeCount]) <= this.contiguous)
|
|
83
|
+
this.safeCount++;
|
|
84
|
+
const blocked = this.records[this.safeCount];
|
|
85
|
+
return blocked && blocked.time <= time ? blocked.time - 1n : time;
|
|
86
|
+
}
|
|
87
|
+
snapshot(time) {
|
|
88
|
+
this.update();
|
|
89
|
+
const target = this.target(time);
|
|
90
|
+
if (!target)
|
|
91
|
+
return [];
|
|
92
|
+
const order = decodeOrder(target);
|
|
93
|
+
let begin;
|
|
94
|
+
for (const [index, keyframe] of this.keyframes)
|
|
95
|
+
if (index <= order &&
|
|
96
|
+
keyframe.time <= time &&
|
|
97
|
+
(begin === undefined || index > begin))
|
|
98
|
+
begin = index;
|
|
99
|
+
if (begin === undefined)
|
|
100
|
+
throw new Error("MCAP is missing the preceding video keyframe");
|
|
101
|
+
return this.range(begin, this.required(target));
|
|
102
|
+
}
|
|
103
|
+
after(time, submitted) {
|
|
104
|
+
this.update();
|
|
105
|
+
const target = this.target(time);
|
|
106
|
+
return target ? this.range(submitted + 1n, this.required(target)) : [];
|
|
107
|
+
}
|
|
108
|
+
range(begin, end) {
|
|
109
|
+
if (end > this.contiguous)
|
|
110
|
+
throw new Error("Video decode dependencies are not buffered");
|
|
111
|
+
const result = [];
|
|
112
|
+
for (let order = begin; order <= end; order++)
|
|
113
|
+
result.push(this.packets.get(order));
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -19,6 +19,7 @@ export declare class ImageRenderer implements PlaybackListener {
|
|
|
19
19
|
private generation;
|
|
20
20
|
private snapshot;
|
|
21
21
|
private imageTopic?;
|
|
22
|
+
private destroyed;
|
|
22
23
|
private readonly video;
|
|
23
24
|
constructor(canvas: HTMLCanvasElement, options?: ImageRendererOptions);
|
|
24
25
|
onMessage(channel: Topic, time: bigint, value: unknown): void;
|
|
@@ -31,5 +32,6 @@ export declare class ImageRenderer implements PlaybackListener {
|
|
|
31
32
|
private resetSnapshot;
|
|
32
33
|
private clearCanvas;
|
|
33
34
|
reset(): void;
|
|
35
|
+
/** Release decoder and frame resources; later playback events are ignored. */
|
|
34
36
|
destroy(): void;
|
|
35
37
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { compressedVideo, videoSchema } from "../schemas.js";
|
|
2
|
+
import { CompressedVideoDecoder } from "./video-decoder.js";
|
|
3
|
+
/** Canvas view of one video topic, showing the decoded frame at the playback time. */
|
|
4
|
+
export class ImageRenderer {
|
|
5
|
+
canvas;
|
|
6
|
+
options;
|
|
7
|
+
onFrame;
|
|
8
|
+
onError;
|
|
9
|
+
image;
|
|
10
|
+
frameTime = 0n;
|
|
11
|
+
presentationTime;
|
|
12
|
+
frames = new Map();
|
|
13
|
+
latestInputTime;
|
|
14
|
+
presentationFloor = 0n;
|
|
15
|
+
requested;
|
|
16
|
+
generation = 0;
|
|
17
|
+
snapshot = false;
|
|
18
|
+
imageTopic;
|
|
19
|
+
destroyed = false;
|
|
20
|
+
video;
|
|
21
|
+
constructor(canvas, options = {}) {
|
|
22
|
+
this.canvas = canvas;
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.imageTopic = options.imageTopic;
|
|
25
|
+
this.video = new CompressedVideoDecoder((frame, time) => this.present(frame, time), (error) => this.onError?.(error));
|
|
26
|
+
}
|
|
27
|
+
onMessage(channel, time, value) {
|
|
28
|
+
try {
|
|
29
|
+
if (this.destroyed || channel.schemaName !== videoSchema)
|
|
30
|
+
return;
|
|
31
|
+
this.imageTopic ??= channel.topic;
|
|
32
|
+
if (this.imageTopic !== channel.topic)
|
|
33
|
+
return;
|
|
34
|
+
const video = compressedVideo(value);
|
|
35
|
+
if ((this.presentationTime === undefined ||
|
|
36
|
+
time <= this.presentationTime) &&
|
|
37
|
+
(this.latestInputTime === undefined || time > this.latestInputTime))
|
|
38
|
+
this.latestInputTime = time;
|
|
39
|
+
this.video.decode(time, video.data, video.keyframe, video.format);
|
|
40
|
+
if (video.decode_order === channel.metadata.get("video.decode_end"))
|
|
41
|
+
void this.video.flush();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
onState(state) {
|
|
48
|
+
if (this.destroyed)
|
|
49
|
+
return;
|
|
50
|
+
this.presentationTime = state.time;
|
|
51
|
+
if (state.status !== "buffering")
|
|
52
|
+
this.selectFrame();
|
|
53
|
+
}
|
|
54
|
+
onSnapshotStart() {
|
|
55
|
+
if (this.destroyed)
|
|
56
|
+
return;
|
|
57
|
+
this.resetSnapshot();
|
|
58
|
+
this.snapshot = true;
|
|
59
|
+
}
|
|
60
|
+
async onSnapshotEnd(paused) {
|
|
61
|
+
if (this.destroyed)
|
|
62
|
+
return;
|
|
63
|
+
const generation = this.generation;
|
|
64
|
+
this.presentationFloor = this.latestInputTime ?? 0n;
|
|
65
|
+
if (paused)
|
|
66
|
+
await this.video.flush();
|
|
67
|
+
if (generation !== this.generation)
|
|
68
|
+
return;
|
|
69
|
+
this.snapshot = false;
|
|
70
|
+
if (this.latestInputTime === undefined ||
|
|
71
|
+
(paused && (!this.image || this.frameTime < this.presentationFloor)))
|
|
72
|
+
this.clearCanvas();
|
|
73
|
+
if (this.image && this.frameTime >= this.presentationFloor) {
|
|
74
|
+
this.render();
|
|
75
|
+
this.onFrame?.(this.frameTime);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
present(frame, time) {
|
|
79
|
+
if (this.image && time < this.frameTime) {
|
|
80
|
+
frame.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.frames.get(time)?.close();
|
|
84
|
+
this.frames.set(time, frame);
|
|
85
|
+
this.selectFrame();
|
|
86
|
+
}
|
|
87
|
+
selectFrame() {
|
|
88
|
+
let selected;
|
|
89
|
+
for (const time of this.frames.keys())
|
|
90
|
+
if ((this.presentationTime === undefined ||
|
|
91
|
+
time <= this.presentationTime) &&
|
|
92
|
+
(selected === undefined || time > selected))
|
|
93
|
+
selected = time;
|
|
94
|
+
if (selected === undefined)
|
|
95
|
+
return;
|
|
96
|
+
const frame = this.frames.get(selected);
|
|
97
|
+
this.frames.delete(selected);
|
|
98
|
+
for (const [time, earlier] of this.frames)
|
|
99
|
+
if (time < selected) {
|
|
100
|
+
earlier.close();
|
|
101
|
+
this.frames.delete(time);
|
|
102
|
+
}
|
|
103
|
+
this.image?.close();
|
|
104
|
+
this.image = frame;
|
|
105
|
+
this.frameTime = selected;
|
|
106
|
+
if (!this.snapshot && selected >= this.presentationFloor) {
|
|
107
|
+
this.render();
|
|
108
|
+
this.onFrame?.(selected);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
render() {
|
|
112
|
+
if (this.requested !== undefined)
|
|
113
|
+
return;
|
|
114
|
+
this.requested = requestAnimationFrame(() => {
|
|
115
|
+
this.requested = undefined;
|
|
116
|
+
const context = this.canvas.getContext("2d");
|
|
117
|
+
if (!this.image ||
|
|
118
|
+
!context ||
|
|
119
|
+
this.snapshot ||
|
|
120
|
+
this.frameTime < this.presentationFloor)
|
|
121
|
+
return;
|
|
122
|
+
const width = this.image.displayWidth;
|
|
123
|
+
const height = this.image.displayHeight;
|
|
124
|
+
if (this.canvas.width !== width || this.canvas.height !== height) {
|
|
125
|
+
this.canvas.width = width;
|
|
126
|
+
this.canvas.height = height;
|
|
127
|
+
this.canvas.style.aspectRatio = `${width} / ${height}`;
|
|
128
|
+
}
|
|
129
|
+
context.drawImage(this.image, 0, 0);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
resetSnapshot() {
|
|
133
|
+
this.generation++;
|
|
134
|
+
this.video.reset();
|
|
135
|
+
for (const frame of this.frames.values())
|
|
136
|
+
frame.close();
|
|
137
|
+
this.frames.clear();
|
|
138
|
+
this.snapshot = false;
|
|
139
|
+
this.frameTime = 0n;
|
|
140
|
+
this.latestInputTime = undefined;
|
|
141
|
+
this.presentationFloor = 0n;
|
|
142
|
+
this.image?.close();
|
|
143
|
+
this.image = undefined;
|
|
144
|
+
}
|
|
145
|
+
clearCanvas() {
|
|
146
|
+
this.canvas
|
|
147
|
+
.getContext("2d")
|
|
148
|
+
?.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
149
|
+
}
|
|
150
|
+
reset() {
|
|
151
|
+
this.resetSnapshot();
|
|
152
|
+
// The first video topic stays selected across seeks until a full reset.
|
|
153
|
+
this.imageTopic = this.options.imageTopic;
|
|
154
|
+
this.presentationTime = undefined;
|
|
155
|
+
this.clearCanvas();
|
|
156
|
+
}
|
|
157
|
+
/** Release decoder and frame resources; later playback events are ignored. */
|
|
158
|
+
destroy() {
|
|
159
|
+
this.destroyed = true;
|
|
160
|
+
this.reset();
|
|
161
|
+
if (this.requested !== undefined)
|
|
162
|
+
cancelAnimationFrame(this.requested);
|
|
163
|
+
this.requested = undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ImageRenderer } from "./image.js";
|