Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -809,7 +809,7 @@ rosview/
└── infra/
├── workers/ # mcap/bag/db3/hdf5 workers and transport
├── sources/ # IterableSource implementations
└── services/ # HttpFileReader, CachedFilelike, BlobReadable
└── services/ # HttpFileReader, CachedFilelike
```

---
Expand Down
12 changes: 7 additions & 5 deletions docs/ARCHITECTURE.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 压缩) |

---

Expand Down Expand Up @@ -351,7 +351,7 @@ interface Readable {
}

// 本地文件
class BlobReadable implements Readable { ... }
// 使用 @mcap/browser BlobReadable,并适配到 MCAP IReadable API。

// 远程文件(HTTP Range + LRU 缓存)
class CachedFilelike implements Readable {
Expand Down Expand Up @@ -854,7 +854,7 @@ rosview/
└── infra/
├── workers/ # mcap/bag/db3/hdf5 worker 与传输层
├── sources/ # IterableSource 与各格式实现
└── services/ # HttpFileReader、CachedFilelike、BlobReadable
└── services/ # HttpFileReader、CachedFilelike
```

以下能力若在旧版树状图中出现、但上表中未列出,视为 **规划/拆分方向** 或尚未以独立文件落地,以 `git` 与 IDE 为准。
Expand Down Expand Up @@ -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"
Expand Down
34 changes: 24 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
80 changes: 49 additions & 31 deletions src/core/players/IterablePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, bigint>();
private _highFrequencyConsumerSignature = "";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1238,7 +1237,10 @@ export class IterablePlayer implements Player {
this._cursor = cursor;
}

private async _refreshStaleTopicsFromBackfill(nowMs: number, epoch: number): Promise<void> {
private _scheduleStaleTopicsRefresh(nowMs: number, epoch: number): void {
if (this._staleRefreshInFlight) {
return;
}
if (nowMs - this._lastStaleRefreshMs < STALE_TOPIC_REFRESH_COOLDOWN_MS) {
return;
}
Expand All @@ -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;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/features/viewer/RosViewProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,13 +75,17 @@ export const RosViewProvider: React.FC<RosViewProviderProps> = ({
);

const messages = useMemo(() => getRosViewMessages(language), [language]);
const playerPresence = useMessagePipeline(
(state: MessagePipelineState) => state.playerState.presence,
);

return (
<RosViewThemeContext.Provider value={contextValue}>
<div
id="rosview-root"
data-language={language}
data-theme={resolvedTheme}
data-player-presence={playerPresence}
className={`w-full h-full ${resolvedTheme === 'dark' ? 'dark' : ''}`}
>
<IntlProvider locale={intlLocaleFor(language)} defaultLocale="en" messages={messages}>
Expand Down
Loading
Loading