diff --git a/NOTICE b/NOTICE index 1aec4eb..45c80d4 100644 --- a/NOTICE +++ b/NOTICE @@ -62,7 +62,7 @@ react-intl Copyright 2026 IO-AI Tech Licensed under the MIT License; third-party notices ship with the npm package (see node_modules/@ioai/hdf5/THIRD_PARTY_NOTICES.md after install). https://www.npmjs.com/package/@ioai/hdf5 - Source: https://github.com/ioai-tech/hdf5-wasm + Source: https://github.com/ioai-tech/wasm-hdf5 fflate Copyright 2020-2024 Arjun Barrett diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 901956e..e3a03d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,8 +283,8 @@ Fixed-width sidebar (collapsible) on the left with three tabs: | Library | Purpose | |---------|---------| | fflate | General compression/decompression (gzip/deflate/zlib) | -| fzstd | Zstandard decompression (MCAP chunk compression) | -| lz4js / lz4-wasm | LZ4 decompression (ROS1 `.bag` chunk compression) | +| @ioai/wasm-zstd | Vite-friendly WebAssembly Zstandard decompression for MCAP chunk compression | +| lz4js | Browser-safe LZ4 decompression for MCAP/ROS1 chunk compression | --- @@ -358,7 +358,7 @@ interface Readable { } // Local file -class BlobReadable implements Readable { ... } +// Uses @mcap/browser BlobReadable and adapts it to the MCAP IReadable API. // Remote file (HTTP Range + LRU cache) class CachedFilelike implements Readable { @@ -809,7 +809,7 @@ rosview/ └── infra/ ├── workers/ # mcap/bag/db3/hdf5 workers and transport ├── sources/ # IterableSource implementations - └── services/ # HttpFileReader, CachedFilelike, BlobReadable + └── services/ # HttpFileReader, CachedFilelike ``` --- diff --git a/docs/ARCHITECTURE.zh.md b/docs/ARCHITECTURE.zh.md index 76fe1f4..a089f3f 100644 --- a/docs/ARCHITECTURE.zh.md +++ b/docs/ARCHITECTURE.zh.md @@ -276,8 +276,8 @@ | 库 | 说明 | |----|------| | fflate | 通用压缩/解压(gzip/deflate/zlib) | -| lz4-wasm 或 lz4js | LZ4 解压(ROS1 .bag chunk 压缩) | -| fzstd | Zstandard 解压(MCAP chunk 压缩) | +| @ioai/wasm-zstd | Vite 友好的 WebAssembly Zstandard 解压(MCAP chunk 压缩) | +| lz4js | 浏览器安全的 LZ4 解压(MCAP/ROS1 chunk 压缩) | --- @@ -351,7 +351,7 @@ interface Readable { } // 本地文件 -class BlobReadable implements Readable { ... } +// 使用 @mcap/browser BlobReadable,并适配到 MCAP IReadable API。 // 远程文件(HTTP Range + LRU 缓存) class CachedFilelike implements Readable { @@ -854,7 +854,7 @@ rosview/ └── infra/ ├── workers/ # mcap/bag/db3/hdf5 worker 与传输层 ├── sources/ # IterableSource 与各格式实现 - └── services/ # HttpFileReader、CachedFilelike、BlobReadable + └── services/ # HttpFileReader、CachedFilelike ``` 以下能力若在旧版树状图中出现、但上表中未列出,视为 **规划/拆分方向** 或尚未以独立文件落地,以 `git` 与 IDE 为准。 @@ -900,7 +900,9 @@ rosview/ "react-intl": "^10.1.2", "fflate": "^0.8.2", - "fzstd": "^0.1.1", + "@mcap/browser": "^1.1.0", + "@ioai/wasm-zstd": "^1.1.0", + "lz4js": "^0.2.0", "zustand": "^5.0.0", "eventemitter3": "^5.0.1" diff --git a/package-lock.json b/package-lock.json index e7076cd..517c360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.3.3", + "version": "1.3.4", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", @@ -20,6 +20,8 @@ "@foxglove/rosmsg-serialization": "^2.0.4", "@foxglove/rosmsg2-serialization": "^3.0.3", "@ioai/hdf5": "^1.0.0", + "@ioai/wasm-zstd": "^1.1.1", + "@mcap/browser": "^1.1.0", "@mcap/core": "^2.0.2", "@playwright/test": "^1.59.1", "@radix-ui/react-collapsible": "^1.1.12", @@ -56,7 +58,6 @@ "eventemitter3": "^5.0.1", "fflate": "^0.8.2", "flatbuffers": "^25.9.23", - "fzstd": "^0.1.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "intervals-fn": "^3.0.3", @@ -1319,6 +1320,13 @@ "node": ">=22.0.0" } }, + "node_modules/@ioai/wasm-zstd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@ioai/wasm-zstd/-/wasm-zstd-1.1.1.tgz", + "integrity": "sha512-9uWUW3Rp2PDUYzqZ0p4qU9o2uPXHBZYVuWabVdFpX+W2hmfdOWH+nxgl8f7VkFjnonj8nRebaJPd6BouXjaRPw==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1369,6 +1377,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mcap/browser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mcap/browser/-/browser-1.1.0.tgz", + "integrity": "sha512-ezBN8bGHOL241w3wai1YQ7H1PdqDIKCyV0jNogjddkx19sB6K6yUKsSinQ9OsUxQdj3SQIPx6One57qCo7/R3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@mcap/core": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mcap/core/-/core-2.2.1.tgz", @@ -5605,13 +5626,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fzstd": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", - "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", - "dev": true, - "license": "MIT" - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index a61b80e..8ed4725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.3.3", + "version": "1.3.4", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", @@ -97,6 +97,8 @@ "@foxglove/rosmsg-serialization": "^2.0.4", "@foxglove/rosmsg2-serialization": "^3.0.3", "@ioai/hdf5": "^1.0.0", + "@ioai/wasm-zstd": "^1.1.1", + "@mcap/browser": "^1.1.0", "@mcap/core": "^2.0.2", "@playwright/test": "^1.59.1", "@radix-ui/react-collapsible": "^1.1.12", @@ -133,7 +135,6 @@ "eventemitter3": "^5.0.1", "fflate": "^0.8.2", "flatbuffers": "^25.9.23", - "fzstd": "^0.1.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "intervals-fn": "^3.0.3", diff --git a/playwright.config.ts b/playwright.config.ts index daf6eca..a1ca2f5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ // Match vite preview host; static preview is more reliable for Range/HEAD than raw dev. baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry', + screenshot: 'only-on-failure', }, projects: [ { diff --git a/src/core/players/IterablePlayer.ts b/src/core/players/IterablePlayer.ts index 26e42dd..ee918b5 100644 --- a/src/core/players/IterablePlayer.ts +++ b/src/core/players/IterablePlayer.ts @@ -23,6 +23,7 @@ const TRANSPORT_DIAGNOSTICS_POLL_INTERVAL_MS = 2000; const EMPTY_BATCH_BACKFILL_COOLDOWN_MS = 1000; const EMPTY_BATCH_BACKFILL_TRIGGER = 4; const BACKFILL_STALE_THRESHOLD_NS = 1_000_000_000n; +const BACKFILL_STALE_TOPIC_PERIOD_MULTIPLIER = 3; const STALE_TOPIC_REFRESH_COOLDOWN_MS = 500; const SLOW_DISTRIBUTION_MS = 16; @@ -162,6 +163,7 @@ export class IterablePlayer implements Player { private _fallbackBackfillCount = 0; private _lastFallbackBackfillMs = 0; private _lastStaleRefreshMs = 0; + private _staleRefreshInFlight = false; private _isBuffering = false; private _topicLastMessageNs = new Map(); private _highFrequencyConsumerSignature = ""; @@ -1004,10 +1006,7 @@ export class IterablePlayer implements Player { } this._currentTime = nextTime; this._clock.seek(this._currentTime, performance.now()); - await this._refreshStaleTopicsFromBackfill(now, epoch); - if (!this._isPlaybackEpochCurrent(epoch)) { - return; - } + this._scheduleStaleTopicsRefresh(now, epoch); if (this._initialization && toNano(this._currentTime) >= toNano(this._initialization.end)) { if (this._isLooping) { @@ -1238,7 +1237,10 @@ export class IterablePlayer implements Player { this._cursor = cursor; } - private async _refreshStaleTopicsFromBackfill(nowMs: number, epoch: number): Promise { + private _scheduleStaleTopicsRefresh(nowMs: number, epoch: number): void { + if (this._staleRefreshInFlight) { + return; + } if (nowMs - this._lastStaleRefreshMs < STALE_TOPIC_REFRESH_COOLDOWN_MS) { return; } @@ -1250,40 +1252,56 @@ export class IterablePlayer implements Player { const staleTopics = topics.filter((topic) => { const lastNs = this._topicLastMessageNs.get(topic); if (lastNs == null) return true; - return nowNs - lastNs > BACKFILL_STALE_THRESHOLD_NS; + return nowNs - lastNs > this._staleThresholdNsForTopic(topic); }); if (staleTopics.length === 0) { return; } this._lastStaleRefreshMs = nowMs; const referenceTime = this._currentTime; - try { - const messages = await this._source.getBackfillMessages({ - time: referenceTime, - topics: staleTopics, - }); - if (!this._isPlaybackEpochCurrent(epoch)) { - return; - } - // Same reasoning as in _handleEmptyBatch: latched topics would otherwise - // get re-delivered on every refresh tick (~5 Hz), causing panels like - // the 3D/URDF renderer to rebuild from scratch and leak GPU buffers. - const freshMessages = this._filterAlreadyDeliveredMessages(messages); - if (freshMessages.length === 0) { - return; - } - this._distributeMessages(freshMessages, referenceTime); - if (this._debugEnabled) { - console.debug("[Playback] stale refresh " + JSON.stringify({ - staleTopicCount: staleTopics.length, - messageCount: messages.length, - freshCount: freshMessages.length, - currentTime: this._currentTime, - })); + this._staleRefreshInFlight = true; + void (async () => { + try { + const messages = await this._source.getBackfillMessages({ + time: referenceTime, + topics: staleTopics, + }); + if (!this._isPlaybackEpochCurrent(epoch)) { + return; + } + // Same reasoning as in _handleEmptyBatch: latched topics would otherwise + // get re-delivered on every refresh tick (~5 Hz), causing panels like + // the 3D/URDF renderer to rebuild from scratch and leak GPU buffers. + const freshMessages = this._filterAlreadyDeliveredMessages(messages); + if (freshMessages.length === 0) { + return; + } + this._distributeMessages(freshMessages, referenceTime); + if (this._debugEnabled) { + console.debug("[Playback] stale refresh " + JSON.stringify({ + staleTopicCount: staleTopics.length, + messageCount: messages.length, + freshCount: freshMessages.length, + currentTime: this._currentTime, + })); + } + } catch (err) { + console.warn("IterablePlayer: stale topic refresh failed", err); + } finally { + this._staleRefreshInFlight = false; } - } catch (err) { - console.warn("IterablePlayer: stale topic refresh failed", err); + })(); + } + + private _staleThresholdNsForTopic(topic: string): bigint { + const stats = this._initialization?.topicStats[topic]; + const frequency = stats?.frequency; + if (typeof frequency !== "number" || !Number.isFinite(frequency) || frequency <= 0) { + return BACKFILL_STALE_THRESHOLD_NS; } + const topicPeriodNs = BigInt(Math.ceil(1_000_000_000 / frequency)); + const topicThresholdNs = topicPeriodNs * BigInt(BACKFILL_STALE_TOPIC_PERIOD_MULTIPLIER); + return topicThresholdNs > BACKFILL_STALE_THRESHOLD_NS ? topicThresholdNs : BACKFILL_STALE_THRESHOLD_NS; } /** diff --git a/src/features/viewer/RosViewProvider.tsx b/src/features/viewer/RosViewProvider.tsx index c9ac765..44dd09f 100644 --- a/src/features/viewer/RosViewProvider.tsx +++ b/src/features/viewer/RosViewProvider.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from ' import { IntlProvider } from 'react-intl'; import { getRosViewMessages, type RosViewLocale } from '@/shared/intl/loadRosViewMessages'; import { Toaster } from '@/shared/ui/sonner'; +import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; +import type { MessagePipelineState } from '@/core/pipeline/store'; export interface RosViewProviderProps { theme?: 'light' | 'dark' | 'system'; @@ -73,6 +75,9 @@ export const RosViewProvider: React.FC = ({ ); const messages = useMemo(() => getRosViewMessages(language), [language]); + const playerPresence = useMessagePipeline( + (state: MessagePipelineState) => state.playerState.presence, + ); return ( @@ -80,6 +85,7 @@ export const RosViewProvider: React.FC = ({ id="rosview-root" data-language={language} data-theme={resolvedTheme} + data-player-presence={playerPresence} className={`w-full h-full ${resolvedTheme === 'dark' ? 'dark' : ''}`} > diff --git a/src/features/viewer/RosViewerImpl.tsx b/src/features/viewer/RosViewerImpl.tsx index 3495ab6..febd99f 100644 --- a/src/features/viewer/RosViewerImpl.tsx +++ b/src/features/viewer/RosViewerImpl.tsx @@ -67,31 +67,12 @@ import { resolveEmbedChrome, type RosViewerChrome, type RosViewerMode } from './ import { RosViewerLayoutProvider } from './RosViewerLayoutContext'; import type { RosViewExtension } from '@/core/extensions/types'; import { toast } from 'sonner'; -import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url'; -import hdf5WasmUrl from '@ioai/hdf5/wasm/ioai_hdf5.wasm?url'; - -let sqlWasmBinaryPromise: Promise | null = null; -let hdf5WasmBinaryPromise: Promise | null = null; - -async function loadSqlWasmBinary(): Promise { - sqlWasmBinaryPromise ??= fetch(sqlWasmUrl).then((response) => { - if (!response.ok) { - throw new Error(`Failed to load SQL wasm: HTTP ${response.status}`); - } - return response.arrayBuffer(); - }); - return await sqlWasmBinaryPromise; -} - -async function loadHdf5WasmBinary(): Promise { - hdf5WasmBinaryPromise ??= fetch(hdf5WasmUrl).then((response) => { - if (!response.ok) { - throw new Error(`Failed to load HDF5 wasm: HTTP ${response.status}`); - } - return response.arrayBuffer(); - }); - return await hdf5WasmBinaryPromise; -} +import { + loadHdf5WasmBinary, + loadSqlWasmBinary, + loadZstdWasmBinary, + needsZstdWasmForWorker, +} from '@/infra/workers/preloadWorkerWasm'; function extensionForDataset(ds: DatasetItem): string | undefined { if (ds.kind === 'file' && ds.file) { @@ -139,6 +120,7 @@ async function initializePlayerForDataset( typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('workerPerf') === '1'; const sqlWasmBinary = ext === 'db3' ? await loadSqlWasmBinary() : undefined; const hdf5WasmBinary = ext === 'hdf5' || ext === 'h5' ? await loadHdf5WasmBinary() : undefined; + const zstdWasmBinary = needsZstdWasmForWorker(ext) ? await loadZstdWasmBinary() : undefined; if (ds.kind === 'url' && ds.url) { const init: Record = { url: resolveBrowserHttpUrl(ds.url), @@ -151,6 +133,9 @@ async function initializePlayerForDataset( if (hdf5WasmBinary) { init.hdf5WasmBinary = hdf5WasmBinary; } + if (zstdWasmBinary) { + init.zstdWasmBinary = zstdWasmBinary; + } if ( typeof ds.sizeBytes === 'number' && Number.isFinite(ds.sizeBytes) && @@ -171,6 +156,7 @@ async function initializePlayerForDataset( workerPerf, autoDataQualityScan, ...(sqlWasmBinary ? { sqlWasmBinary } : {}), + ...(zstdWasmBinary ? { zstdWasmBinary } : {}), }); } else { await player.initialize({ @@ -178,6 +164,7 @@ async function initializePlayerForDataset( workerPerf, autoDataQualityScan, ...(hdf5WasmBinary ? { hdf5WasmBinary } : {}), + ...(zstdWasmBinary ? { zstdWasmBinary } : {}), }); } return; diff --git a/src/features/workspace/common/LoadingOverlay.tsx b/src/features/workspace/common/LoadingOverlay.tsx index 8f8a5ec..d6a581c 100644 --- a/src/features/workspace/common/LoadingOverlay.tsx +++ b/src/features/workspace/common/LoadingOverlay.tsx @@ -18,6 +18,7 @@ export const LoadingOverlay: React.FC = ({ sourceName, onCa role="status" aria-live="polite" aria-busy="true" + data-testid="rosview-loading-overlay" > diff --git a/src/infra/services/BlobReadable.ts b/src/infra/services/BlobReadable.ts deleted file mode 100644 index 7e26d26..0000000 --- a/src/infra/services/BlobReadable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Readable } from '@/core/types/player'; - -export class BlobReadable implements Readable { - private _blob: Blob; - - constructor(blob: Blob) { - this._blob = blob; - } - - size(): Promise { - return Promise.resolve(this._blob.size); - } - - async read(offset: number, length: number): Promise { - const slice = this._blob.slice(offset, offset + length); - const arrayBuffer = await slice.arrayBuffer(); - return new Uint8Array(arrayBuffer); - } -} diff --git a/src/infra/sources/BagIterableSource.ts b/src/infra/sources/BagIterableSource.ts index bad4898..66a1541 100644 --- a/src/infra/sources/BagIterableSource.ts +++ b/src/infra/sources/BagIterableSource.ts @@ -47,16 +47,18 @@ function asTime(value: unknown): Time { export class BagIterableSource implements IIterableSource { private _source: BagSource; + private _wasmBinary: ArrayBuffer; private _bag?: Bag; private _datatypesByConnectionId = new Map(); private _readersByConnectionId = new Map(); - constructor(source: BagSource) { + constructor(source: BagSource, options: { wasmBinary: ArrayBuffer }) { this._source = source; + this._wasmBinary = options.wasmBinary; } async initialize(): Promise { - const decompressHandlers = await loadDecompressHandlers(); + const decompressHandlers = await loadDecompressHandlers({ wasmBinary: this._wasmBinary }); const fileLike: BlobReader | RemoteBagReadable = this._source.type === "remote" ? this._source.readable : new BlobReader(this._source.file); diff --git a/src/infra/sources/decompressHandlers.ts b/src/infra/sources/decompressHandlers.ts index c65d3a4..2b99257 100644 --- a/src/infra/sources/decompressHandlers.ts +++ b/src/infra/sources/decompressHandlers.ts @@ -1,19 +1,18 @@ import type { McapTypes } from "@mcap/core"; -import { decompress as fzstdDecompress } from "fzstd"; import * as lz4js from "lz4js"; +import { decompress as zstdDecompress } from "@ioai/wasm-zstd"; +import { ensureZstdRuntime } from "@/infra/workers/zstdRuntimeLoader"; -// Use pure-JavaScript / ESM-compatible decompression libraries instead of -// the @foxglove/wasm-* packages, which are CommonJS + Node.js Buffer and -// cannot run inside an ES module worker in Vite dev mode. +export type LoadDecompressHandlersOptions = { + wasmBinary: ArrayBuffer; +}; -let handlersPromise: Promise | undefined; +export async function loadDecompressHandlers( + options: LoadDecompressHandlersOptions, +): Promise { + await ensureZstdRuntime(options.wasmBinary); -export async function loadDecompressHandlers(): Promise { - return await (handlersPromise ??= _loadDecompressHandlers()); -} - -function _loadDecompressHandlers(): Promise { - return Promise.resolve({ + return { lz4: (buffer, decompressedSize) => { const output = new Uint8Array(Number(decompressedSize)); const result = lz4js.decompressBlock(buffer, output, 0, buffer.byteLength, 0); @@ -23,8 +22,7 @@ function _loadDecompressHandlers(): Promise { return output; }, zstd: (buffer, decompressedSize) => { - const output = new Uint8Array(Number(decompressedSize)); - return fzstdDecompress(buffer, output); + return zstdDecompress(buffer, Number(decompressedSize)); }, - }); + }; } diff --git a/src/infra/workers/MessageCursor.ts b/src/infra/workers/MessageCursor.ts index 6b16f4d..39e5bac 100644 --- a/src/infra/workers/MessageCursor.ts +++ b/src/infra/workers/MessageCursor.ts @@ -9,10 +9,17 @@ import { workerPerf } from "./workerPerf"; type MessageCursorOptions = { latestOnlyTopics?: readonly string[]; + maxBufferDurationMs?: number; }; type MessageCursorConfig = WorkerTransportConfig & MessageCursorOptions; +function yieldToEventLoop(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + function toThrownError(err: unknown): Error { if (err instanceof Error) return err; try { @@ -25,12 +32,15 @@ function toThrownError(err: unknown): Error { export class MessageCursor implements IMessageCursor { private static readonly DEFAULT_MAX_BATCH_MESSAGES = 256; private static readonly DEFAULT_MAX_BATCH_WALL_MS = 6; - private static readonly DEFAULT_MAX_BUFFER_MESSAGES = 2048; - private static readonly DEFAULT_MAX_BUFFER_BYTES = 128 * 1024 * 1024; + private static readonly DEFAULT_MAX_BUFFER_MESSAGES = 768; + private static readonly DEFAULT_MAX_BUFFER_BYTES = 32 * 1024 * 1024; private static readonly EMPTY_QUEUE_WAIT_MS = 50; + private static readonly MAX_PUMP_SLICE_MESSAGES = 64; + private static readonly MAX_PUMP_SLICE_WALL_MS = 8; private _iterator: AsyncIterableIterator; private _transportConfig: WorkerTransportConfig; private _latestOnlyTopics: ReadonlySet; + private _maxBufferDurationMs: number | undefined; private _sharedRing?: SharedPayloadRing; private _queue: MessageEvent[] = []; private _queueBytes = 0; @@ -48,6 +58,10 @@ export class MessageCursor implements IMessageCursor { this._iterator = iterator; this._transportConfig = transportConfig; this._latestOnlyTopics = new Set(transportConfig.latestOnlyTopics ?? []); + const maxBufferDurationMs = transportConfig.maxBufferDurationMs; + this._maxBufferDurationMs = typeof maxBufferDurationMs === "number" && Number.isFinite(maxBufferDurationMs) + ? Math.max(1, maxBufferDurationMs) + : undefined; if (transportConfig.mode === "sab" && transportConfig.payloadRing) { this._sharedRing = new SharedPayloadRing(transportConfig.payloadRing); } @@ -75,15 +89,26 @@ export class MessageCursor implements IMessageCursor { const maxWallTimeMs = Math.max(1, options?.maxWallTimeMs ?? MessageCursor.DEFAULT_MAX_BATCH_WALL_MS); const messages: MessageEvent[] = []; const startTime = Date.now(); - + const waitStart = performance.now(); + await this._waitForQueue(MessageCursor.EMPTY_QUEUE_WAIT_MS); + workerPerf.record("cursor.nextBatch.waitForQueue", performance.now() - waitStart); if (this._pumpError) { throw toThrownError(this._pumpError); } const first = this._takeNextQueuedMessage(); - if (!first) return []; + if (!first) { + workerPerf.recordGauge("cursor.queue.messages", this._queue.length); + workerPerf.recordGauge("cursor.queue.mb", this._queueBytes / (1024 * 1024)); + workerPerf.recordGauge("cursor.queue.durationMs", this._queueDurationMs()); + workerPerf.recordGauge("cursor.nextBatch.rawMessages", 0); + workerPerf.recordGauge("cursor.nextBatch.sentMessages", 0); + return []; + } if (options?.endTime && toNano(first.receiveTime) > toNano(options.endTime)) { this._pendingMessage = first; + workerPerf.recordGauge("cursor.nextBatch.rawMessages", 0); + workerPerf.recordGauge("cursor.nextBatch.sentMessages", 0); return []; } messages.push(first); @@ -103,8 +128,15 @@ export class MessageCursor implements IMessageCursor { if (messages.length >= maxMessages) break; } + const coalesced = this._coalesceLatestOnlyTopics(messages); + workerPerf.recordGauge("cursor.queue.messages", this._queue.length); + workerPerf.recordGauge("cursor.queue.mb", this._queueBytes / (1024 * 1024)); + workerPerf.recordGauge("cursor.queue.durationMs", this._queueDurationMs()); + workerPerf.recordGauge("cursor.nextBatch.rawMessages", messages.length); + workerPerf.recordGauge("cursor.nextBatch.sentMessages", coalesced.length); + const transferred = this._transferBatch(coalesced); workerPerf.record("cursor.nextBatch.total", performance.now() - batchStart); - return this._transferBatch(this._coalesceLatestOnlyTopics(messages)); + return transferred; } async end(): Promise { @@ -118,10 +150,13 @@ export class MessageCursor implements IMessageCursor { private async _pump(): Promise { try { + let sliceStart = performance.now(); + let sliceMessages = 0; while (!this._closed && !this._done) { if ( this._queue.length >= MessageCursor.DEFAULT_MAX_BUFFER_MESSAGES || - this._queueBytes >= MessageCursor.DEFAULT_MAX_BUFFER_BYTES + this._queueBytes >= MessageCursor.DEFAULT_MAX_BUFFER_BYTES || + this._isPastBufferDurationLimit() ) { await this._waitForCapacity(); continue; @@ -138,6 +173,21 @@ export class MessageCursor implements IMessageCursor { this._queue.push(normalized); this._queueBytes += this._estimateMessageBytes(normalized); this._notifyQueueWaiters(); + + sliceMessages += 1; + const elapsedMs = performance.now() - sliceStart; + if ( + sliceMessages >= MessageCursor.MAX_PUMP_SLICE_MESSAGES || + elapsedMs >= MessageCursor.MAX_PUMP_SLICE_WALL_MS + ) { + workerPerf.record("cursor.pump.slice", elapsedMs); + workerPerf.recordGauge("cursor.queue.messages", this._queue.length); + workerPerf.recordGauge("cursor.queue.mb", this._queueBytes / (1024 * 1024)); + workerPerf.recordGauge("cursor.queue.durationMs", this._queueDurationMs()); + await yieldToEventLoop(); + sliceStart = performance.now(); + sliceMessages = 0; + } } } catch (error) { if (!this._closed) { @@ -192,7 +242,8 @@ export class MessageCursor implements IMessageCursor { if ( this._closed || (this._queue.length < MessageCursor.DEFAULT_MAX_BUFFER_MESSAGES && - this._queueBytes < MessageCursor.DEFAULT_MAX_BUFFER_BYTES) + this._queueBytes < MessageCursor.DEFAULT_MAX_BUFFER_BYTES && + !this._isPastBufferDurationLimit()) ) { return; } @@ -262,6 +313,23 @@ export class MessageCursor implements IMessageCursor { }); } + private _queueDurationMs(): number { + const first = this._pendingMessage ?? this._queue[0]; + const last = this._queue[this._queue.length - 1] ?? this._pendingMessage; + if (!first || !last) { + return 0; + } + const durationNs = toNano(last.receiveTime) - toNano(first.receiveTime); + if (durationNs <= 0n) { + return 0; + } + return Number(durationNs) / 1e6; + } + + private _isPastBufferDurationLimit(): boolean { + return this._maxBufferDurationMs != undefined && this._queueDurationMs() >= this._maxBufferDurationMs; + } + private _prepareMessageForTransport(event: MessageEvent): MessageEvent { if (this._transportConfig.mode !== "sab" || !this._sharedRing) { return event; diff --git a/src/infra/workers/bag.worker.ts b/src/infra/workers/bag.worker.ts index c2e79b4..f7daf73 100644 --- a/src/infra/workers/bag.worker.ts +++ b/src/infra/workers/bag.worker.ts @@ -69,7 +69,14 @@ class BagWorker implements IWorkerSerializedSourceWorker { throw new Error("Invalid arguments for BagWorker"); } - this._source = new BagIterableSource(sourceArgs); + const zstdWasmBinary = args.zstdWasmBinary; + if (!(zstdWasmBinary instanceof ArrayBuffer)) { + throw new Error( + "BagWorker: zstdWasmBinary required (pass wasm bytes from main thread for inline workers)", + ); + } + + this._source = new BagIterableSource(sourceArgs, { wasmBinary: zstdWasmBinary }); const init = await this._source.initialize(); this._initialization = init; this._qualityScan.initialize(this._source, init, args.autoDataQualityScan === true); diff --git a/src/infra/workers/mcap.worker.ts b/src/infra/workers/mcap.worker.ts index 946c04a..d96e39e 100644 --- a/src/infra/workers/mcap.worker.ts +++ b/src/infra/workers/mcap.worker.ts @@ -1,6 +1,7 @@ import * as Comlink from "comlink"; +import { BlobReadable } from "@mcap/browser"; import { McapIndexedReader } from "@mcap/core"; -import type { Readable } from '@/core/types/player'; +import type { IReadable } from "@mcap/core"; import type { Initialization, MessageEvent, TimeRange } from '@/core/types/ros'; import type { IWorkerSerializedSourceWorker, @@ -16,7 +17,6 @@ import { loadDecompressHandlers } from '@/infra/sources/decompressHandlers'; import { MessageCursor } from "./MessageCursor"; import { HttpFileReader } from '@/infra/services/HttpFileReader'; import CachedFilelike from '@/infra/services/CachedFilelike'; -import { BlobReadable } from '@/infra/services/BlobReadable'; import { resolveWorkerHttpUrl } from '@/shared/utils/resolveWorkerHttpUrl'; import type { LoadProgress } from "./types"; import type { TransportDiagnostics, WorkerTransportConfig } from "./transport"; @@ -41,6 +41,7 @@ const MAX_PREFETCH_BYTES = 768 * MIB; const MAX_PREFETCH_HORIZON_NS = 15_000_000_000n; const MAX_CONTIGUOUS_CHUNK_GAP_NS = 750_000_000n; const DEFAULT_PREFETCH_AHEAD_MS = 5_000; +const PLAYBACK_CURSOR_BUFFER_AHEAD_MS = 1_500; function isByteRangeCovered(query: Range, downloaded: readonly Range[]): boolean { return downloaded.some((range) => range.start <= query.start && range.end >= query.end); @@ -67,7 +68,7 @@ class McapWorkerImpl implements IWorkerSerializedSourceWorker { }); console.log("McapWorker: initialize starting", args); try { - let readable: Readable; + let rawReadable: IReadable; const url = typeof args.url === 'string' ? args.url : undefined; const file = args.file instanceof Blob ? args.file : undefined; if (url) { @@ -83,32 +84,44 @@ class McapWorkerImpl implements IWorkerSerializedSourceWorker { knownTotalBytes != null ? { knownTotalBytes } : undefined, ); this._remoteCacheBytes = resolveRemoteCacheBytes(); - this._cachedReadable = new CachedFilelike({ + const cachedReadable = new CachedFilelike({ fileReader, cacheSizeInBytes: this._remoteCacheBytes, preferCacheViews: true, }); - readable = this._cachedReadable; + this._cachedReadable = cachedReadable; + rawReadable = { + size: async () => BigInt(await cachedReadable.size()), + read: async (offset: bigint, length: bigint) => + await cachedReadable.read(Number(offset), Number(length)), + }; } else if (file) { - readable = new BlobReadable(file); + rawReadable = new BlobReadable(file); this._cachedReadable = undefined; this._totalBytes = file.size; } else { throw new Error("McapWorker: neither url nor file provided"); } + const zstdWasmBinary = args.zstdWasmBinary; + if (!(zstdWasmBinary instanceof ArrayBuffer)) { + throw new Error( + "McapWorker: zstdWasmBinary required (pass wasm bytes from main thread for inline workers)", + ); + } + const decompressHandlers = await workerPerf.timeAsync( "initialize.loadDecompressHandlers", - () => loadDecompressHandlers(), + () => loadDecompressHandlers({ wasmBinary: zstdWasmBinary }), ); const mcapReadable = { - size: async () => BigInt(await readable.size()), + size: async () => await rawReadable.size(), read: async (offset: bigint, length: bigint) => { const byteLength = Number(length); return await workerPerf.timeAsync( "mcapReadable.read", - async () => await readable.read(Number(offset), byteLength), + async () => await rawReadable.read(offset, length), byteLength, ); } @@ -174,10 +187,12 @@ class McapWorkerImpl implements IWorkerSerializedSourceWorker { this._prefetchAnchor = args.startTime; this._scheduleTimePrefixPrefetch(); const iterator = this._source.messageIterator(args); - return Promise.resolve(Comlink.proxy(new MessageCursor(iterator, { + const cursorOptions = { ...this._transportConfig, latestOnlyTopics: args.latestOnlyTopics, - }))); + ...(args.endTime == undefined ? { maxBufferDurationMs: PLAYBACK_CURSOR_BUFFER_AHEAD_MS } : {}), + }; + return Promise.resolve(Comlink.proxy(new MessageCursor(iterator, cursorOptions))); } async getBackfillMessages(args: GetBackfillMessagesArgs): Promise { diff --git a/src/infra/workers/preloadWorkerWasm.ts b/src/infra/workers/preloadWorkerWasm.ts new file mode 100644 index 0000000..8c77656 --- /dev/null +++ b/src/infra/workers/preloadWorkerWasm.ts @@ -0,0 +1,26 @@ +import { loadWasmBinary } from '@/shared/utils/loadWasmBinary'; +import { hdf5WasmUrl, sqlWasmUrl, zstdWasmUrl } from './wasmAssetUrls'; + +export function loadSqlWasmBinary(): Promise { + return loadWasmBinary(sqlWasmUrl, 'SQL'); +} + +export function loadHdf5WasmBinary(): Promise { + return loadWasmBinary(hdf5WasmUrl, 'HDF5'); +} + +/** Preload @ioai/wasm-zstd bytes on the main thread for inline workers. */ +export function loadZstdWasmBinary(): Promise { + return loadWasmBinary(zstdWasmUrl, 'zstd'); +} + +/** Inline workers that call `loadDecompressHandlers` (MCAP + bag). */ +export function needsZstdWasmForWorker(ext: string | undefined): boolean { + if (ext === 'bag') { + return true; + } + if (ext === 'bvh' || ext === 'db3' || ext === 'hdf5' || ext === 'h5') { + return false; + } + return true; +} diff --git a/src/infra/workers/wasmAssetUrls.ts b/src/infra/workers/wasmAssetUrls.ts new file mode 100644 index 0000000..ee236b2 --- /dev/null +++ b/src/infra/workers/wasmAssetUrls.ts @@ -0,0 +1,5 @@ +import hdf5WasmUrl from '@ioai/hdf5/wasm/ioai_hdf5.wasm?url'; +import zstdWasmUrl from '@ioai/wasm-zstd/wasm-zstd.wasm?url'; +import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url'; + +export { hdf5WasmUrl, sqlWasmUrl, zstdWasmUrl }; diff --git a/src/infra/workers/workerPerf.ts b/src/infra/workers/workerPerf.ts index 4bfe595..00a7090 100644 --- a/src/infra/workers/workerPerf.ts +++ b/src/infra/workers/workerPerf.ts @@ -25,6 +25,7 @@ class WorkerPerfCollector { private _startedAtMs = 0; private _buckets = new Map(); private _topicBuckets = new Map(); + private _gauges = new Map(); configure(config: WorkerPerfConfig): void { this._enabled = config.enabled; @@ -34,6 +35,7 @@ class WorkerPerfCollector { this._startedAtMs = this._lastFlushMs; this._buckets.clear(); this._topicBuckets.clear(); + this._gauges.clear(); if (this._enabled) { console.info(`[WorkerPerf:${this._label}] enabled`); } @@ -92,6 +94,14 @@ class WorkerPerfCollector { this.flushMaybe(); } + recordGauge(name: string, value: number): void { + if (!this._enabled || !Number.isFinite(value)) { + return; + } + this._gauges.set(name, Number(value.toFixed(3))); + this.flushMaybe(); + } + flushMaybe(force = false): void { if (!this._enabled) { return; @@ -112,6 +122,7 @@ class WorkerPerfCollector { elapsedMs: Math.round(now - this._startedAtMs), buckets, topTopics: topics, + gauges: Object.fromEntries(this._gauges), })}`); } diff --git a/src/infra/workers/zstdRuntimeLoader.ts b/src/infra/workers/zstdRuntimeLoader.ts new file mode 100644 index 0000000..445bd4f --- /dev/null +++ b/src/infra/workers/zstdRuntimeLoader.ts @@ -0,0 +1,14 @@ +import { init } from '@ioai/wasm-zstd'; + +let initPromise: Promise | undefined; + +/** + * Initialize @ioai/wasm-zstd for inline (`?worker&inline`) workers. + * + * Inline workers run from a blob: URL, so the main thread passes preloaded wasm + * bytes and lets @ioai/wasm-zstd initialize through its public API. + */ +export function ensureZstdRuntime(wasmBinary: ArrayBuffer): Promise { + initPromise ??= init({ wasmBinary }); + return initPromise; +} diff --git a/src/shared/utils/loadWasmBinary.ts b/src/shared/utils/loadWasmBinary.ts new file mode 100644 index 0000000..1d09717 --- /dev/null +++ b/src/shared/utils/loadWasmBinary.ts @@ -0,0 +1,19 @@ +const cache = new Map>(); + +/** + * Fetch and cache a `.wasm` asset referenced by Vite's `?url` import. + * Main thread preloads bytes for inline (`?worker&inline`) workers. + */ +export function loadWasmBinary(wasmUrl: string, label: string): Promise { + let promise = cache.get(wasmUrl); + if (!promise) { + promise = fetch(wasmUrl).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${label} wasm: HTTP ${response.status}`); + } + return response.arrayBuffer(); + }); + cache.set(wasmUrl, promise); + } + return promise; +} diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts index 6adcfc8..a7e9435 100644 --- a/tests/basic.spec.ts +++ b/tests/basic.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from '@playwright/test'; import { MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; +import { + attachBrowserDiagnostics, + expectDockviewTopic, + openFixtureByUrl, +} from './helpers/rosview'; test.beforeAll(() => { requireExamplesDir(); @@ -34,94 +39,95 @@ test('layout menu lists import, export, save, reset', async ({ page }) => { await expect(page.locator('#rosview-navbar-layout-import')).toBeAttached(); }); -test('keyboard shortcuts work', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`); - - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 15000 }); +test.describe('MCAP playback', () => { + test.describe.configure({ timeout: 90_000 }); + + test('keyboard shortcuts work', async ({ page }) => { + const diagnostics = attachBrowserDiagnostics(page); + await openFixtureByUrl(page, MCAP_BASIC_URL, { diagnostics }); + await expectDockviewTopic(page, '/camera/'); + + await page.evaluate(() => { + const el = document.activeElement; + if (el instanceof HTMLElement) el.blur(); + }); + + const loopMode = page.getByTestId('playback-loop-trigger'); + await expect(loopMode).toBeVisible(); + await expect(loopMode).toContainText('Loop'); + await page.keyboard.press('Space'); + await expect(page.getByRole('button', { name: 'Pause playback' })).toBeVisible(); + await page.keyboard.press('Space'); + await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); + }); - await page.evaluate(() => { - const el = document.activeElement; - if (el instanceof HTMLElement) el.blur(); + test('playback bar supports hover, drag and loop menu', async ({ page }) => { + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); + + const track = page.getByTestId('playback-track'); + await expect(track).toBeVisible(); + await track.hover(); + await expect(page.getByTestId('playback-hover-time')).toBeVisible(); + + const box = await track.boundingBox(); + if (!box) { + throw new Error('playback-track has no bounding box'); + } + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.7, box.y + box.height / 2); + await page.mouse.up(); + + await expect(page.getByTestId('playback-thumb')).toBeVisible(); + + const loopMode = page.getByTestId('playback-loop-trigger'); + await expect(loopMode).toContainText('Loop'); + await loopMode.click(); + await page.getByTestId('playback-loop-option-once').click(); + await expect(loopMode).toContainText('Once'); + await loopMode.click(); + await page.getByTestId('playback-loop-option-loop').click(); + await expect(loopMode).toContainText('Loop'); }); - await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); - const loopMode = page.getByTestId('playback-loop-trigger'); - await expect(loopMode).toBeVisible(); - await expect(loopMode).toContainText('Loop'); - await page.keyboard.press('Space'); - await expect(page.getByRole('button', { name: 'Pause playback' })).toBeVisible(); - await page.keyboard.press('Space'); - await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); -}); + test('playback updates image frames and supports sampling FPS switch', async ({ page }) => { + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); -test('playback bar supports hover, drag and loop menu', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 15000 }); - - const track = page.getByTestId('playback-track'); - await expect(track).toBeVisible(); - await track.hover(); - await expect(page.getByTestId('playback-hover-time')).toBeVisible(); - - const box = await track.boundingBox(); - if (!box) { - throw new Error('playback-track has no bounding box'); - } - await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2); - await page.mouse.down(); - await page.mouse.move(box.x + box.width * 0.7, box.y + box.height / 2); - await page.mouse.up(); - - await expect(page.getByTestId('playback-thumb')).toBeVisible(); - - const loopMode = page.getByTestId('playback-loop-trigger'); - await expect(loopMode).toContainText('Loop'); - await loopMode.click(); - await page.getByTestId('playback-loop-option-once').click(); - await expect(loopMode).toContainText('Once'); - await loopMode.click(); - await page.getByTestId('playback-loop-option-loop').click(); - await expect(loopMode).toContainText('Loop'); -}); + const fpsSelect = page.getByTestId('playback-fps-trigger'); + await expect(fpsSelect).toBeVisible(); + await fpsSelect.click(); + await page.getByTestId('playback-fps-option-15').click(); + await expect(fpsSelect).toContainText('15'); -test('playback updates image frames and supports sampling FPS switch', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); - - const fpsSelect = page.getByTestId('playback-fps-trigger'); - await expect(fpsSelect).toBeVisible(); - await fpsSelect.click(); - await page.getByTestId('playback-fps-option-15').click(); - await expect(fpsSelect).toContainText('15'); - - const canvas = page.locator('canvas').first(); - await expect(canvas).toBeVisible({ timeout: 45_000 }); - await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); - await page.getByRole('button', { name: 'Play playback' }).click(); - await expect(canvas).toBeVisible(); -}); + const canvas = page.locator('canvas').first(); + await expect(canvas).toBeVisible({ timeout: 45_000 }); + await page.getByRole('button', { name: 'Play playback' }).click(); + await expect(canvas).toBeVisible(); + }); -test('dockview main region resizes with the window', async ({ page }) => { - const prev = page.viewportSize(); - await page.goto(`/?url=${MCAP_BASIC_URL}`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); - - const dock = page.getByTestId('rosview-dockview'); - await expect(dock).toBeVisible(); - const box1 = await dock.boundingBox(); - expect(box1 && box1.width > 80 && box1.height > 80).toBeTruthy(); - - await page.setViewportSize({ width: 720, height: 520 }); - await expect(async () => { - const box2 = await dock.boundingBox(); - expect( - box2 && - box1 && - (Math.abs(box2.width - box1.width) > 2 || Math.abs(box2.height - box1.height) > 2), - ).toBeTruthy(); - }).toPass({ timeout: 15_000, intervals: [50, 100, 200, 400] }); - - if (prev) { - await page.setViewportSize({ width: prev.width, height: prev.height }); - } + test('dockview main region resizes with the window', async ({ page }) => { + const prev = page.viewportSize(); + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); + + const dock = page.getByTestId('rosview-dockview'); + const box1 = await dock.boundingBox(); + expect(box1 && box1.width > 80 && box1.height > 80).toBeTruthy(); + + await page.setViewportSize({ width: 720, height: 520 }); + await expect(async () => { + const box2 = await dock.boundingBox(); + expect( + box2 && + box1 && + (Math.abs(box2.width - box1.width) > 2 || Math.abs(box2.height - box1.height) > 2), + ).toBeTruthy(); + }).toPass({ timeout: 15_000, intervals: [50, 100, 200, 400] }); + + if (prev) { + await page.setViewportSize({ width: prev.width, height: prev.height }); + } + }); }); diff --git a/tests/delivery.spec.ts b/tests/delivery.spec.ts index 0e4c1ce..07dc7fc 100644 --- a/tests/delivery.spec.ts +++ b/tests/delivery.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { MCAP_BASIC, MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; +import { expectDockviewTopic, openFixtureByUrl, waitForRosviewReady } from './helpers/rosview'; test.beforeAll(() => { requireExamplesDir(); @@ -17,25 +18,28 @@ test('zh UI from ?lang=zh-CN query (SEO-friendly)', async ({ page }) => { await expect(page.getByRole('heading', { level: 1 })).toHaveText('ROS View'); }); -test('remote single url opens fixture', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); - await expect(page.locator('nav')).toContainText('test_5s.mcap', { timeout: 15_000 }); -}); +test.describe('MCAP delivery', () => { + test.describe.configure({ timeout: 90_000 }); -test('dockview theme class follows light mode', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}&theme=light`); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 30_000 }); - const dock = page.getByTestId('rosview-dockview'); - await expect(dock).toHaveAttribute('data-dockview-chrome-theme', 'light'); - await expect(page.locator('.ros-dockview-theme-light').first()).toBeVisible(); -}); + test('remote single url opens fixture', async ({ page }) => { + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); + await expect(page.locator('nav')).toContainText('test_5s.mcap', { timeout: 15_000 }); + }); + + test('dockview theme class follows light mode', async ({ page }) => { + await openFixtureByUrl(page, MCAP_BASIC_URL, { query: { theme: 'light' } }); + const dock = page.getByTestId('rosview-dockview'); + await expect(dock).toHaveAttribute('data-dockview-chrome-theme', 'light'); + await expect(page.locator('.ros-dockview-theme-light').first()).toBeVisible(); + }); -test.describe('local file upload', () => { - test('local file via welcome hidden file input', async ({ page }) => { - await page.goto('/'); - await page.locator('#rosview-landing-file').setInputFiles(MCAP_BASIC); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + test.describe('local file upload', () => { + test('local file via welcome hidden file input', async ({ page }) => { + await page.goto('/'); + await page.locator('#rosview-landing-file').setInputFiles(MCAP_BASIC); + await waitForRosviewReady(page); + await expectDockviewTopic(page, '/camera/'); + }); }); }); diff --git a/tests/dockview-chrome.spec.ts b/tests/dockview-chrome.spec.ts index fdff2ef..eebb856 100644 --- a/tests/dockview-chrome.spec.ts +++ b/tests/dockview-chrome.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; +import { openFixtureByUrl } from './helpers/rosview'; async function openFirstTabChromeMenuIfNeeded(page: import('@playwright/test').Page): Promise { const directAdd = page.getByTestId('panel-tab-add-button').first(); @@ -19,16 +20,14 @@ test.describe('Dockview chrome', () => { }); test('shows dockview, group add split, and tab close control', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); await openFirstTabChromeMenuIfNeeded(page); await expect(page.getByTestId('panel-tab-add-button').first()).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('panel-tab-close-button').first()).toBeVisible(); }); test('primary add creates a new tab in the group', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); await openFirstTabChromeMenuIfNeeded(page); await expect(page.getByTestId('panel-tab-add-button').first()).toBeVisible({ timeout: 30_000 }); @@ -45,8 +44,7 @@ test.describe('Dockview chrome', () => { }); test('tab context menu offers Close', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); const firstTab = page.locator('.dv-tab').first(); await firstTab.click({ button: 'right' }); await expect(page.getByRole('menuitem', { name: 'Close', exact: true })).toBeVisible(); @@ -54,16 +52,14 @@ test.describe('Dockview chrome', () => { }); test('tab labels show panel type (not topic basename)', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); await expect(page.locator('.dv-tab').filter({ hasText: /^Image$/ }).first()).toBeVisible({ timeout: 30_000, }); }); test('dockview theme class follows dark mode', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}&theme=dark`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL, { query: { theme: 'dark' } }); const dock = page.getByTestId('rosview-dockview'); await expect(dock).toHaveAttribute('data-dockview-chrome-theme', 'dark'); await expect(page.locator('.ros-dockview-theme-dark').first()).toBeVisible(); diff --git a/tests/helpers/rosview.ts b/tests/helpers/rosview.ts new file mode 100644 index 0000000..39771a0 --- /dev/null +++ b/tests/helpers/rosview.ts @@ -0,0 +1,90 @@ +import { expect, type Page } from '@playwright/test'; + +export type BrowserDiagnostics = { + pageErrors: string[]; + consoleErrors: string[]; +}; + +export function attachBrowserDiagnostics(page: Page): BrowserDiagnostics { + const diagnostics: BrowserDiagnostics = { + pageErrors: [], + consoleErrors: [], + }; + + page.on('pageerror', (error) => { + diagnostics.pageErrors.push(error.message); + }); + + page.on('console', (message) => { + if (message.type() === 'error') { + diagnostics.consoleErrors.push(message.text()); + } + }); + + return diagnostics; +} + +function formatDiagnostics(diagnostics: BrowserDiagnostics): string { + const lines: string[] = []; + if (diagnostics.pageErrors.length > 0) { + lines.push(`page errors:\n${diagnostics.pageErrors.map((e) => ` - ${e}`).join('\n')}`); + } + if (diagnostics.consoleErrors.length > 0) { + lines.push(`console errors:\n${diagnostics.consoleErrors.map((e) => ` - ${e}`).join('\n')}`); + } + return lines.join('\n'); +} + +export type OpenFixtureOptions = { + timeoutMs?: number; + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + diagnostics?: BrowserDiagnostics; + /** Extra query params merged with `url=`. */ + query?: Record; +}; + +export async function openFixtureByUrl( + page: Page, + url: string, + options: OpenFixtureOptions = {}, +): Promise { + const { waitUntil = 'domcontentloaded', query = {} } = options; + const params = new URLSearchParams({ url, ...query }); + await page.goto(`/?${params.toString()}`, { waitUntil }); + await waitForRosviewReady(page, options); +} + +export async function waitForRosviewReady( + page: Page, + options: OpenFixtureOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 60_000; + const diagnostics = options.diagnostics; + + try { + await expect(page.locator('#rosview-root')).toHaveAttribute('data-player-presence', 'ready', { + timeout: timeoutMs, + }); + await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: timeoutMs }); + await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible({ + timeout: timeoutMs, + }); + } catch (error) { + if (diagnostics && (diagnostics.pageErrors.length > 0 || diagnostics.consoleErrors.length > 0)) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${formatDiagnostics(diagnostics)}`, + ); + } + throw error; + } +} + +export async function expectDockviewTopic( + page: Page, + substring: string, + timeoutMs = 30_000, +): Promise { + await expect(page.getByTestId('rosview-dockview')).toContainText(substring, { + timeout: timeoutMs, + }); +} diff --git a/tests/image-h264.spec.ts b/tests/image-h264.spec.ts index 32ecb97..36a0258 100644 --- a/tests/image-h264.spec.ts +++ b/tests/image-h264.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { MCAP_H264_URL, requireFixture, MCAP_H264 } from './fixturePaths'; +import { openFixtureByUrl } from './helpers/rosview'; test.describe.configure({ timeout: 120_000 }); @@ -8,8 +9,7 @@ test.beforeAll(() => { }); test('H.264 CompressedImage decodes without error', async ({ page }) => { - await page.goto(`/?url=${MCAP_H264_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_H264_URL); const play = page.getByRole('button', { name: 'Play playback' }); if (await play.isVisible().catch(() => false)) { diff --git a/tests/multi-sources.spec.ts b/tests/multi-sources.spec.ts index 5092c64..73852c2 100644 --- a/tests/multi-sources.spec.ts +++ b/tests/multi-sources.spec.ts @@ -1,20 +1,24 @@ import { test, expect } from '@playwright/test'; import { MCAP_BASIC, MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; +import { expectDockviewTopic, openFixtureByUrl, waitForRosviewReady } from './helpers/rosview'; test.describe('multi-source URLs and sidebar', () => { + test.describe.configure({ timeout: 90_000 }); + test.beforeAll(() => { requireExamplesDir(); }); test('single url= opens fixture', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); await expect(page.getByTestId('playback-loaded-range').first()).toBeVisible(); }); test('local file via welcome hidden file input', async ({ page }) => { await page.goto('/'); await page.locator('#rosview-landing-file').setInputFiles(MCAP_BASIC); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await waitForRosviewReady(page); + await expectDockviewTopic(page, '/camera/'); }); }); diff --git a/tests/pose-panel.spec.ts b/tests/pose-panel.spec.ts index 4465e3d..de357da 100644 --- a/tests/pose-panel.spec.ts +++ b/tests/pose-panel.spec.ts @@ -1,15 +1,15 @@ import { test, expect } from '@playwright/test'; import { MCAP_POSE_URL, requireFixture, MCAP_POSE } from './fixturePaths'; +import { openFixtureByUrl } from './helpers/rosview'; + +test.describe.configure({ timeout: 90_000 }); test.beforeAll(() => { requireFixture(MCAP_POSE); }); test('PoseStamped fixture exposes pose topics by schema', async ({ page }) => { - await page.goto(`/?url=${MCAP_POSE_URL}`, { - waitUntil: 'domcontentloaded', - }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + await openFixtureByUrl(page, MCAP_POSE_URL); await expect(page.getByText('geometry_msgs/msg/PoseStamped').first()).toBeVisible({ timeout: 30_000, diff --git a/tests/ros-image-grid.spec.ts b/tests/ros-image-grid.spec.ts index 1c6da68..98e81e1 100644 --- a/tests/ros-image-grid.spec.ts +++ b/tests/ros-image-grid.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { MCAP_3CAM_URL, requireFixture, MCAP_3CAM } from './fixturePaths'; +import { attachBrowserDiagnostics, openFixtureByUrl } from './helpers/rosview'; test.describe.configure({ timeout: 120_000 }); @@ -8,16 +9,8 @@ test.beforeAll(() => { }); test('loads the three-camera compressed image sample without empty decoder payloads', async ({ page }) => { - const pageErrors: string[] = []; - page.on('pageerror', (error) => pageErrors.push(error.message)); - page.on('console', (message) => { - if (message.type() === 'error') { - pageErrors.push(message.text()); - } - }); - - await page.goto(`/?url=${MCAP_3CAM_URL}`, { waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); + const diagnostics = attachBrowserDiagnostics(page); + await openFixtureByUrl(page, MCAP_3CAM_URL, { diagnostics }); const imagePanels = page.getByTestId('image-panel'); await expect(imagePanels).toHaveCount(3, { timeout: 60_000 }); @@ -39,5 +32,6 @@ test('loads the three-camera compressed image sample without empty decoder paylo await page.waitForTimeout(4_000); await expect(page.getByTestId('image-panel-status')).toHaveCount(3); await expect(page.getByText(/Image decode failed|Compressed image payload is empty|No image data provided/i)).toHaveCount(0); - expect(pageErrors.filter((entry) => /ImageDecoder|No image data provided/i.test(entry))).toEqual([]); + expect(diagnostics.pageErrors.filter((entry) => /ImageDecoder|No image data provided/i.test(entry))).toEqual([]); + expect(diagnostics.consoleErrors.filter((entry) => /ImageDecoder|No image data provided/i.test(entry))).toEqual([]); }); diff --git a/tests/transport-fallback.spec.ts b/tests/transport-fallback.spec.ts index 09d6594..327e1c7 100644 --- a/tests/transport-fallback.spec.ts +++ b/tests/transport-fallback.spec.ts @@ -1,26 +1,29 @@ import { test, expect } from '@playwright/test'; import { MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; +import { expectDockviewTopic, openFixtureByUrl } from './helpers/rosview'; test.describe('transport fallback', () => { + test.describe.configure({ timeout: 90_000 }); + test.beforeAll(() => { requireExamplesDir(); }); test('exposes selected transport mode on dockview shell', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL); + await expectDockviewTopic(page, '/camera/'); await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', /^(sab|transfer|comlink)$/); }); test('query parameter forces transfer mode', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}&transport=transfer`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL, { query: { transport: 'transfer' } }); + await expectDockviewTopic(page, '/camera/'); await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', 'transfer'); }); test('query parameter forces comlink mode', async ({ page }) => { - await page.goto(`/?url=${MCAP_BASIC_URL}&transport=comlink`); - await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await openFixtureByUrl(page, MCAP_BASIC_URL, { query: { transport: 'comlink' } }); + await expectDockviewTopic(page, '/camera/'); await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', 'comlink'); }); }); diff --git a/vite.config.ts b/vite.config.ts index 7bf8995..3956ac0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,10 +56,11 @@ export default defineConfig({ '@foxglove/rosmsg', '@foxglove/rosmsg-serialization', '@foxglove/rosmsg2-serialization', + '@ioai/wasm-zstd', + '@mcap/browser', '@mcap/core', 'eventemitter3', 'flatbuffers/js/flexbuffers.js', - 'fzstd', 'intervals-fn', 'lz4js', 'protobufjs',