memmap-replay-buffer 0.2.1__tar.gz → 0.2.3__tar.gz
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.
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/PKG-INFO +23 -1
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/README.md +17 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/__init__.py +2 -0
- memmap_replay_buffer-0.2.3/memmap_replay_buffer/concat_replay_buffer.py +93 -0
- memmap_replay_buffer-0.2.3/memmap_replay_buffer/frontend/index.html +285 -0
- memmap_replay_buffer-0.2.3/memmap_replay_buffer/frontend.py +155 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/replay_buffer.py +580 -623
- memmap_replay_buffer-0.2.3/memmap_replay_buffer/replay_buffer_h5py.py +233 -0
- memmap_replay_buffer-0.2.3/memmap_replay_buffer/video.py +214 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/pyproject.toml +11 -2
- memmap_replay_buffer-0.2.3/tests/test_frontend.py +172 -0
- memmap_replay_buffer-0.2.3/tests/test_video.py +254 -0
- memmap_replay_buffer-0.2.1/memmap_replay_buffer/concat_replay_buffer.py +0 -94
- memmap_replay_buffer-0.2.1/memmap_replay_buffer/replay_buffer_h5py.py +0 -969
- memmap_replay_buffer-0.2.1/tests/test_e2e.py +0 -376
- memmap_replay_buffer-0.2.1/tests/test_edge_cases.py +0 -384
- memmap_replay_buffer-0.2.1/tests/test_lazy_init.py +0 -68
- memmap_replay_buffer-0.2.1/tests/test_mixed_storage.py +0 -81
- memmap_replay_buffer-0.2.1/tests/test_nstep_timestep_gotcha.py +0 -31
- memmap_replay_buffer-0.2.1/tests/test_rollout_collector.py +0 -187
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/.gitignore +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/LICENSE +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/episode_collector.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_episode_collector.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_get_all_data.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_n_step.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_replay_buffer.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_replay_buffer_h5py.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_scalar_flexibility.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_store_episode_parity.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_time_window.py +0 -0
- {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.3}/tests/test_uint8_storage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memmap-replay-buffer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Simple Replay Buffer for RL
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/memmap-replay-buffer/
|
|
6
6
|
Project-URL: Repository, https://codeberg.org/lucidrains/memmap-replay-buffer
|
|
@@ -40,7 +40,12 @@ Requires-Dist: numpy
|
|
|
40
40
|
Requires-Dist: torch>=2.4
|
|
41
41
|
Provides-Extra: test
|
|
42
42
|
Requires-Dist: h5py; extra == 'test'
|
|
43
|
+
Requires-Dist: imageio; extra == 'test'
|
|
44
|
+
Requires-Dist: imageio-ffmpeg; extra == 'test'
|
|
43
45
|
Requires-Dist: pytest; extra == 'test'
|
|
46
|
+
Provides-Extra: video
|
|
47
|
+
Requires-Dist: imageio; extra == 'video'
|
|
48
|
+
Requires-Dist: imageio-ffmpeg; extra == 'video'
|
|
44
49
|
Description-Content-Type: text/markdown
|
|
45
50
|
|
|
46
51
|
## memmap-replay-buffer
|
|
@@ -206,6 +211,23 @@ buffer.update(0, returns = returns_for_one) # scalar index
|
|
|
206
211
|
all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
|
|
207
212
|
```
|
|
208
213
|
|
|
214
|
+
### Rendering episodes to video
|
|
215
|
+
|
|
216
|
+
When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`. Pass a list of episodes to concatenate them in time.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
|
|
220
|
+
buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
|
|
224
|
+
|
|
225
|
+
The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend. `--episode` and `--field` accept comma-delimited lists - with multiple cameras (image-like fields), one video is written per field (`<name>.<field>.<ext>`) and the frontend plays them side by side, synchronized, with a live frame counter to the side. The frontend has frame-level controls: a timeline scrubber, single-frame stepping (buttons, left/right arrows, shift for 10, or mouse wheel over the videos), play/pause, and slowdown playback (down to 0.1x).
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
$ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
|
|
229
|
+
```
|
|
230
|
+
|
|
209
231
|
### Reopening, read-only, and clearing
|
|
210
232
|
|
|
211
233
|
`overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
|
|
@@ -161,6 +161,23 @@ buffer.update(0, returns = returns_for_one) # scalar index
|
|
|
161
161
|
all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### Rendering episodes to video
|
|
165
|
+
|
|
166
|
+
When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`. Pass a list of episodes to concatenate them in time.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
|
|
170
|
+
buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
|
|
174
|
+
|
|
175
|
+
The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend. `--episode` and `--field` accept comma-delimited lists - with multiple cameras (image-like fields), one video is written per field (`<name>.<field>.<ext>`) and the frontend plays them side by side, synchronized, with a live frame counter to the side. The frontend has frame-level controls: a timeline scrubber, single-frame stepping (buttons, left/right arrows, shift for 10, or mouse wheel over the videos), play/pause, and slowdown playback (down to 0.1x).
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
$ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
|
|
179
|
+
```
|
|
180
|
+
|
|
164
181
|
### Reopening, read-only, and clearing
|
|
165
182
|
|
|
166
183
|
`overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
|
|
@@ -12,3 +12,5 @@ except ImportError:
|
|
|
12
12
|
pass
|
|
13
13
|
|
|
14
14
|
from memmap_replay_buffer.episode_collector import EpisodeCollector as EpisodeCollector
|
|
15
|
+
from memmap_replay_buffer.video import frames_to_sheet as frames_to_sheet
|
|
16
|
+
from memmap_replay_buffer.video import trajectory_to_video as trajectory_to_video
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor, cat
|
|
7
|
+
from torch.utils.data import ConcatDataset, Dataset
|
|
8
|
+
|
|
9
|
+
from memmap_replay_buffer.replay_buffer import ReplayBuffer, default, exists, pad_right_at_dim
|
|
10
|
+
|
|
11
|
+
class ConcatReplayBuffer:
|
|
12
|
+
def __init__(self, folders: list[str | Path]):
|
|
13
|
+
if len(folders) == 0:
|
|
14
|
+
raise ValueError('ConcatReplayBuffer requires at least one folder')
|
|
15
|
+
|
|
16
|
+
self.buffers = [ReplayBuffer.from_folder(folder, read_only = True) for folder in folders]
|
|
17
|
+
|
|
18
|
+
first_buffer = self.buffers[0]
|
|
19
|
+
self.fieldnames = first_buffer.fieldnames
|
|
20
|
+
self.meta_fieldnames = first_buffer.meta_fieldnames
|
|
21
|
+
|
|
22
|
+
for buffer in self.buffers[1:]:
|
|
23
|
+
if buffer.fieldnames != self.fieldnames:
|
|
24
|
+
raise ValueError(f'All buffers must have the same fieldnames - got {buffer.fieldnames} and {self.fieldnames}')
|
|
25
|
+
|
|
26
|
+
if buffer.meta_fieldnames != self.meta_fieldnames:
|
|
27
|
+
raise ValueError(f'All buffers must have the same meta_fieldnames - got {buffer.meta_fieldnames} and {self.meta_fieldnames}')
|
|
28
|
+
|
|
29
|
+
self.read_only = True
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def num_episodes(self):
|
|
33
|
+
return sum(buffer.num_episodes for buffer in self.buffers)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def max_episodes(self):
|
|
37
|
+
return sum(buffer.max_episodes for buffer in self.buffers)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def max_timesteps(self):
|
|
41
|
+
return max(buffer.max_timesteps for buffer in self.buffers)
|
|
42
|
+
|
|
43
|
+
def __len__(self):
|
|
44
|
+
return sum(len(buffer) for buffer in self.buffers)
|
|
45
|
+
|
|
46
|
+
def dataset(self, **kwargs) -> Dataset:
|
|
47
|
+
if len(self) == 0:
|
|
48
|
+
raise ValueError('replay buffer is empty')
|
|
49
|
+
|
|
50
|
+
datasets = [buffer.dataset(**kwargs) for buffer in self.buffers if len(buffer) > 0]
|
|
51
|
+
return ConcatDataset(datasets)
|
|
52
|
+
|
|
53
|
+
def dataloader(self, *args, **kwargs):
|
|
54
|
+
return ReplayBuffer.dataloader(self, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
def get_all_data(
|
|
57
|
+
self,
|
|
58
|
+
fields: tuple[str, ...] | None = None,
|
|
59
|
+
meta_fields: tuple[str, ...] | None = None,
|
|
60
|
+
device: torch.device | str | None = None
|
|
61
|
+
) -> dict[str, Tensor]:
|
|
62
|
+
|
|
63
|
+
all_data = [buffer.get_all_data(fields = fields, meta_fields = meta_fields, device = device) for buffer in self.buffers]
|
|
64
|
+
all_data = [data for data in all_data if len(data) > 0]
|
|
65
|
+
|
|
66
|
+
if not all_data:
|
|
67
|
+
return dict()
|
|
68
|
+
|
|
69
|
+
data_fields = self.fieldnames if not exists(fields) and not exists(meta_fields) else default(fields, ())
|
|
70
|
+
|
|
71
|
+
out = dict()
|
|
72
|
+
|
|
73
|
+
for key in all_data[0].keys():
|
|
74
|
+
tensors = [data[key] for data in all_data if key in data]
|
|
75
|
+
|
|
76
|
+
if key in data_fields and tensors[0].ndim > 1:
|
|
77
|
+
# pad trajectories to the longest across buffers
|
|
78
|
+
max_time = max(t.shape[1] for t in tensors)
|
|
79
|
+
tensors = [pad_right_at_dim(t, max_time - t.shape[1], dim = 1) for t in tensors]
|
|
80
|
+
|
|
81
|
+
out[key] = cat(tensors, dim = 0)
|
|
82
|
+
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
def flush(self):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def _read_only(self, *args, **kwargs):
|
|
89
|
+
raise NotImplementedError('ConcatReplayBuffer is read-only')
|
|
90
|
+
|
|
91
|
+
clear = reset_ = advance_episode = store_batch = store_meta_batch = \
|
|
92
|
+
one_episode = batched_episode = store_datapoint = store_meta_datapoint = \
|
|
93
|
+
store_batch_datapoint = store_batch_meta_datapoint = store = store_episode = update = _read_only
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>inspect-replay-buffer</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: grid;
|
|
14
|
+
place-items: center;
|
|
15
|
+
background: #0f1012;
|
|
16
|
+
color: #e8e8e8;
|
|
17
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
18
|
+
}
|
|
19
|
+
#stage {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: 12px;
|
|
23
|
+
width: min(calc(100vw - 340px), 1200px);
|
|
24
|
+
}
|
|
25
|
+
main {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
28
|
+
gap: 12px;
|
|
29
|
+
}
|
|
30
|
+
figure { margin: 0; min-width: 0; }
|
|
31
|
+
figure video { width: 100%; max-height: 74vh; display: block; border-radius: 6px; background: #000; }
|
|
32
|
+
figcaption { margin-top: 6px; color: #8a8f98; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
33
|
+
#controls {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 10px;
|
|
37
|
+
padding: 8px 12px;
|
|
38
|
+
background: #17181b;
|
|
39
|
+
border: 1px solid #26282c;
|
|
40
|
+
border-radius: 6px;
|
|
41
|
+
}
|
|
42
|
+
#controls button {
|
|
43
|
+
width: 40px;
|
|
44
|
+
padding: 4px 0;
|
|
45
|
+
background: #1f2126;
|
|
46
|
+
color: #e8e8e8;
|
|
47
|
+
border: 1px solid #2e3136;
|
|
48
|
+
border-radius: 4px;
|
|
49
|
+
font: inherit;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
#controls button:hover { background: #26282c; }
|
|
53
|
+
#timeline { flex: 1; accent-color: #7fb4e6; min-width: 0; }
|
|
54
|
+
#controls .time { color: #8a8f98; white-space: nowrap; }
|
|
55
|
+
#speed {
|
|
56
|
+
background: #1f2126;
|
|
57
|
+
color: #e8e8e8;
|
|
58
|
+
border: 1px solid #2e3136;
|
|
59
|
+
border-radius: 4px;
|
|
60
|
+
padding: 4px;
|
|
61
|
+
font: inherit;
|
|
62
|
+
}
|
|
63
|
+
aside {
|
|
64
|
+
position: fixed;
|
|
65
|
+
top: 50%;
|
|
66
|
+
right: 20px;
|
|
67
|
+
transform: translateY(-50%);
|
|
68
|
+
width: 240px;
|
|
69
|
+
max-height: 88vh;
|
|
70
|
+
overflow: auto;
|
|
71
|
+
padding: 16px;
|
|
72
|
+
background: #17181b;
|
|
73
|
+
border: 1px solid #26282c;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
}
|
|
76
|
+
aside h1 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #7fb4e6; }
|
|
77
|
+
aside h2 { margin: 16px 0 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a8f98; }
|
|
78
|
+
dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
|
|
79
|
+
dt { color: #8a8f98; }
|
|
80
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
81
|
+
#extras:empty { display: none; }
|
|
82
|
+
@media (max-width: 1000px) {
|
|
83
|
+
body { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px; }
|
|
84
|
+
#stage { width: 100%; max-width: 640px; }
|
|
85
|
+
aside { position: static; transform: none; width: 100%; max-width: 480px; }
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div id="stage">
|
|
91
|
+
<main id="replays"></main>
|
|
92
|
+
<footer id="controls">
|
|
93
|
+
<button id="step-back" title="previous frame (left arrow)">⟨</button>
|
|
94
|
+
<button id="play-pause" title="play / pause (space)">▶</button>
|
|
95
|
+
<button id="step-forward" title="next frame (right arrow)">⟩</button>
|
|
96
|
+
<input id="timeline" type="range" min="0" value="0" step="1" />
|
|
97
|
+
<span id="time" class="time">0 / 0</span>
|
|
98
|
+
<select id="speed">
|
|
99
|
+
<option value="0.1">0.1x</option>
|
|
100
|
+
<option value="0.25">0.25x</option>
|
|
101
|
+
<option value="0.5">0.5x</option>
|
|
102
|
+
<option value="1" selected>1x</option>
|
|
103
|
+
<option value="2">2x</option>
|
|
104
|
+
<option value="4">4x</option>
|
|
105
|
+
</select>
|
|
106
|
+
</footer>
|
|
107
|
+
</div>
|
|
108
|
+
<aside>
|
|
109
|
+
<h1>replay buffer</h1>
|
|
110
|
+
<dl id="stats"></dl>
|
|
111
|
+
<h2>fields</h2>
|
|
112
|
+
<dl id="field-stats"></dl>
|
|
113
|
+
<h2>time</h2>
|
|
114
|
+
<dl>
|
|
115
|
+
<dt>frame</dt><dd id="frame">0</dd>
|
|
116
|
+
</dl>
|
|
117
|
+
<div id="extras"></div>
|
|
118
|
+
</aside>
|
|
119
|
+
<script>
|
|
120
|
+
const stats = __REPLAY_STATS__;
|
|
121
|
+
const fields = stats.fields ?? [{ name: 'video' }];
|
|
122
|
+
const extras = document.getElementById('extras');
|
|
123
|
+
|
|
124
|
+
const statsList = document.getElementById('stats');
|
|
125
|
+
for (const [key, value] of Object.entries(stats)) {
|
|
126
|
+
if (key === 'fields') continue;
|
|
127
|
+
|
|
128
|
+
const dt = document.createElement('dt');
|
|
129
|
+
dt.textContent = key;
|
|
130
|
+
const dd = document.createElement('dd');
|
|
131
|
+
dd.textContent = Array.isArray(value) ? value.join(', ') : value;
|
|
132
|
+
statsList.append(dt, dd);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fieldList = document.getElementById('field-stats');
|
|
136
|
+
for (const field of fields) {
|
|
137
|
+
const dt = document.createElement('dt');
|
|
138
|
+
dt.textContent = field.name;
|
|
139
|
+
const dd = document.createElement('dd');
|
|
140
|
+
dd.textContent = [field.shape, field.dtype].filter(Boolean).join(' ') || 'video';
|
|
141
|
+
fieldList.append(dt, dd);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// one video per field, every video follows the first
|
|
145
|
+
|
|
146
|
+
const replays = document.getElementById('replays');
|
|
147
|
+
const single = fields.length === 1;
|
|
148
|
+
|
|
149
|
+
const videos = fields.map((field, index) => {
|
|
150
|
+
const figure = document.createElement('figure');
|
|
151
|
+
const video = document.createElement('video');
|
|
152
|
+
|
|
153
|
+
video.src = single ? '/video' : `/video/${encodeURIComponent(field.name)}`;
|
|
154
|
+
video.controls = false;
|
|
155
|
+
video.autoplay = true;
|
|
156
|
+
video.loop = true;
|
|
157
|
+
video.muted = true;
|
|
158
|
+
video.playsInline = true;
|
|
159
|
+
|
|
160
|
+
const caption = document.createElement('figcaption');
|
|
161
|
+
caption.textContent = field.name;
|
|
162
|
+
|
|
163
|
+
figure.append(video, caption);
|
|
164
|
+
replays.append(figure);
|
|
165
|
+
return video;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const [master, ...slaves] = videos;
|
|
169
|
+
|
|
170
|
+
const sync = () => {
|
|
171
|
+
for (const slave of slaves) {
|
|
172
|
+
if (Math.abs(slave.currentTime - master.currentTime) > 0.08) {
|
|
173
|
+
slave.currentTime = master.currentTime;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
slaves.forEach(slave => slave.addEventListener('loadedmetadata', sync));
|
|
179
|
+
|
|
180
|
+
master.addEventListener('play', () => {
|
|
181
|
+
playPause.textContent = '⏸';
|
|
182
|
+
slaves.forEach(video => video.play());
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
master.addEventListener('pause', () => {
|
|
186
|
+
playPause.textContent = '▶';
|
|
187
|
+
slaves.forEach(video => video.pause());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
master.addEventListener('ratechange', () => {
|
|
191
|
+
slaves.forEach(video => video.playbackRate = master.playbackRate);
|
|
192
|
+
speed.value = String(master.playbackRate);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
master.addEventListener('seeking', sync);
|
|
196
|
+
master.addEventListener('seeked', sync);
|
|
197
|
+
master.addEventListener('timeupdate', sync);
|
|
198
|
+
|
|
199
|
+
const tick = () => {
|
|
200
|
+
if (!master.paused) sync();
|
|
201
|
+
requestAnimationFrame(tick);
|
|
202
|
+
};
|
|
203
|
+
requestAnimationFrame(tick);
|
|
204
|
+
|
|
205
|
+
// frame-level controls
|
|
206
|
+
|
|
207
|
+
const playPause = document.getElementById('play-pause');
|
|
208
|
+
const timeline = document.getElementById('timeline');
|
|
209
|
+
const timeLabel = document.getElementById('time');
|
|
210
|
+
const speed = document.getElementById('speed');
|
|
211
|
+
const frameLabel = document.getElementById('frame');
|
|
212
|
+
|
|
213
|
+
const totalFrames = () => stats.frames ?? (Number.isFinite(master.duration) ? Math.round(master.duration * stats.fps) : 0);
|
|
214
|
+
const frameFromTime = (time) => Math.max(0, Math.min(Math.round(time * stats.fps), totalFrames() - 1));
|
|
215
|
+
|
|
216
|
+
const updateTime = () => {
|
|
217
|
+
const frame = frameFromTime(master.currentTime);
|
|
218
|
+
const total = totalFrames();
|
|
219
|
+
|
|
220
|
+
frameLabel.textContent = `${frame} / ${total}`;
|
|
221
|
+
timeLabel.textContent = `${frame} / ${total}`;
|
|
222
|
+
timeline.max = String(Math.max(total - 1, 0));
|
|
223
|
+
|
|
224
|
+
if (!scrubbing) timeline.value = String(frame);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
let scrubbing = false;
|
|
228
|
+
timeline.addEventListener('pointerdown', () => scrubbing = true);
|
|
229
|
+
window.addEventListener('pointerup', () => { if (scrubbing) { scrubbing = false; updateTime(); } });
|
|
230
|
+
|
|
231
|
+
const seekToFrame = (frame) => {
|
|
232
|
+
master.currentTime = Math.max(0, Math.min(frame, totalFrames() - 1)) / stats.fps;
|
|
233
|
+
sync();
|
|
234
|
+
updateTime();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
timeline.addEventListener('input', () => {
|
|
238
|
+
master.pause();
|
|
239
|
+
seekToFrame(Number(timeline.value));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
playPause.addEventListener('click', () => master.paused ? master.play() : master.pause());
|
|
243
|
+
document.getElementById('step-back').addEventListener('click', () => { master.pause(); seekToFrame(frameFromTime(master.currentTime) - 1); });
|
|
244
|
+
document.getElementById('step-forward').addEventListener('click', () => { master.pause(); seekToFrame(frameFromTime(master.currentTime) + 1); });
|
|
245
|
+
|
|
246
|
+
speed.addEventListener('change', () => master.playbackRate = Number(speed.value));
|
|
247
|
+
|
|
248
|
+
// mouse wheel over the videos steps through frames
|
|
249
|
+
|
|
250
|
+
replays.addEventListener('wheel', (event) => {
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
master.pause();
|
|
253
|
+
seekToFrame(frameFromTime(master.currentTime) + Math.sign(event.deltaY));
|
|
254
|
+
}, { passive: false });
|
|
255
|
+
|
|
256
|
+
window.addEventListener('keydown', (event) => {
|
|
257
|
+
if (event.target.matches('input, select, button, textarea')) return;
|
|
258
|
+
|
|
259
|
+
const step = event.shiftKey ? 10 : 1;
|
|
260
|
+
|
|
261
|
+
if (event.key === ' ') {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
master.paused ? master.play() : master.pause();
|
|
264
|
+
} else if (event.key === 'ArrowLeft') {
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
master.pause();
|
|
267
|
+
seekToFrame(frameFromTime(master.currentTime) - step);
|
|
268
|
+
} else if (event.key === 'ArrowRight') {
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
master.pause();
|
|
271
|
+
seekToFrame(frameFromTime(master.currentTime) + step);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
master.addEventListener('timeupdate', updateTime);
|
|
276
|
+
master.addEventListener('loadedmetadata', updateTime);
|
|
277
|
+
master.addEventListener('play', updateTime);
|
|
278
|
+
master.addEventListener('pause', updateTime);
|
|
279
|
+
updateTime();
|
|
280
|
+
|
|
281
|
+
// future: sync value predictions / actions / other per-timestep elements to `master.currentTime`
|
|
282
|
+
window.replay = { stats, fields, videos, master, timeline, seekToFrame, extras };
|
|
283
|
+
</script>
|
|
284
|
+
</body>
|
|
285
|
+
</html>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import threading
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
# helpers
|
|
12
|
+
|
|
13
|
+
def normalize_stats(stats: dict):
|
|
14
|
+
""" fold the legacy single-field stats keys into a `fields` list """
|
|
15
|
+
stats = dict(stats)
|
|
16
|
+
|
|
17
|
+
if 'fields' not in stats and 'field' in stats:
|
|
18
|
+
stats['fields'] = [dict(
|
|
19
|
+
name = stats.pop('field'),
|
|
20
|
+
shape = stats.pop('shape', ''),
|
|
21
|
+
dtype = stats.pop('dtype', '')
|
|
22
|
+
)]
|
|
23
|
+
|
|
24
|
+
return stats
|
|
25
|
+
|
|
26
|
+
def normalize_videos(videos):
|
|
27
|
+
if isinstance(videos, dict):
|
|
28
|
+
return {str(name): Path(path) for name, path in videos.items()}
|
|
29
|
+
|
|
30
|
+
return dict(video = Path(videos))
|
|
31
|
+
|
|
32
|
+
def content_type(path: Path):
|
|
33
|
+
return mimetypes.guess_type(path.name)[0] or 'video/mp4'
|
|
34
|
+
|
|
35
|
+
def render_page(stats: dict):
|
|
36
|
+
template_path = Path(__file__).parent / 'frontend' / 'index.html'
|
|
37
|
+
return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(normalize_stats(stats)))
|
|
38
|
+
|
|
39
|
+
def parse_range(range_header: str | None, size: int):
|
|
40
|
+
if not range_header or not range_header.startswith('bytes='):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
start_str, _, end_str = range_header[len('bytes='):].split(',')[0].partition('-')
|
|
44
|
+
|
|
45
|
+
if not start_str:
|
|
46
|
+
# suffix range, e.g. bytes=-500
|
|
47
|
+
return max(size - int(end_str), 0), size - 1
|
|
48
|
+
|
|
49
|
+
return int(start_str), min(int(end_str), size - 1) if end_str else size - 1
|
|
50
|
+
|
|
51
|
+
# server
|
|
52
|
+
|
|
53
|
+
class ReplayBufferHandler(BaseHTTPRequestHandler):
|
|
54
|
+
video_paths: dict[str, Path]
|
|
55
|
+
page: str
|
|
56
|
+
|
|
57
|
+
protocol_version = 'HTTP/1.1'
|
|
58
|
+
|
|
59
|
+
def log_message(self, *args):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def do_GET(self):
|
|
63
|
+
path = urlparse(self.path).path
|
|
64
|
+
|
|
65
|
+
if path == '/':
|
|
66
|
+
return self.send_page()
|
|
67
|
+
|
|
68
|
+
video_path = self.video_for_path(path)
|
|
69
|
+
|
|
70
|
+
if video_path is not None:
|
|
71
|
+
return self.send_video(video_path)
|
|
72
|
+
|
|
73
|
+
self.send_error(404)
|
|
74
|
+
|
|
75
|
+
def video_for_path(self, path):
|
|
76
|
+
# `/video` serves the first video for backward compatibility
|
|
77
|
+
|
|
78
|
+
if path == '/video':
|
|
79
|
+
return next(iter(self.video_paths.values()), None)
|
|
80
|
+
|
|
81
|
+
if path.startswith('/video/'):
|
|
82
|
+
return self.video_paths.get(unquote(path[len('/video/'):]))
|
|
83
|
+
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def send_page(self):
|
|
87
|
+
body = self.page.encode()
|
|
88
|
+
|
|
89
|
+
self.send_response(200)
|
|
90
|
+
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
91
|
+
self.send_header('Content-Length', str(len(body)))
|
|
92
|
+
self.end_headers()
|
|
93
|
+
self.wfile.write(body)
|
|
94
|
+
|
|
95
|
+
def send_video(self, video_path):
|
|
96
|
+
size = video_path.stat().st_size
|
|
97
|
+
byte_range = parse_range(self.headers.get('Range'), size)
|
|
98
|
+
|
|
99
|
+
start, end = byte_range if byte_range else (0, size - 1)
|
|
100
|
+
is_partial = byte_range is not None
|
|
101
|
+
|
|
102
|
+
self.send_response(206 if is_partial else 200)
|
|
103
|
+
self.send_header('Content-Type', content_type(video_path))
|
|
104
|
+
self.send_header('Content-Length', str(end - start + 1))
|
|
105
|
+
self.send_header('Accept-Ranges', 'bytes')
|
|
106
|
+
|
|
107
|
+
if is_partial:
|
|
108
|
+
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
|
109
|
+
|
|
110
|
+
self.end_headers()
|
|
111
|
+
|
|
112
|
+
# stream the selected slice, seeking works via repeated range requests
|
|
113
|
+
|
|
114
|
+
with open(video_path, 'rb') as f:
|
|
115
|
+
f.seek(start)
|
|
116
|
+
remaining = end - start + 1
|
|
117
|
+
|
|
118
|
+
while remaining > 0:
|
|
119
|
+
chunk = f.read(min(remaining, 1 << 20))
|
|
120
|
+
|
|
121
|
+
if not chunk:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
self.wfile.write(chunk)
|
|
125
|
+
remaining -= len(chunk)
|
|
126
|
+
|
|
127
|
+
def make_server(videos: str | Path | dict[str, Path], stats: dict, host = '127.0.0.1', port = 8000):
|
|
128
|
+
handler = type('Handler', (ReplayBufferHandler,), dict(
|
|
129
|
+
video_paths = normalize_videos(videos),
|
|
130
|
+
page = render_page(stats)
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
return ThreadingHTTPServer((host, port), handler)
|
|
134
|
+
|
|
135
|
+
def serve(videos: str | Path | dict[str, Path], stats: dict, host = '127.0.0.1', port = 8000, open_browser = True, block = True):
|
|
136
|
+
httpd = make_server(videos, stats, host, port)
|
|
137
|
+
url = f'http://{host}:{httpd.server_address[1]}/'
|
|
138
|
+
|
|
139
|
+
print(f'serving replay at {url} (ctrl-c to stop)')
|
|
140
|
+
|
|
141
|
+
if open_browser:
|
|
142
|
+
threading.Thread(target = webbrowser.open, args = (url,), daemon = True).start()
|
|
143
|
+
|
|
144
|
+
if not block:
|
|
145
|
+
threading.Thread(target = httpd.serve_forever, daemon = True).start()
|
|
146
|
+
return httpd, url
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
httpd.serve_forever()
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
pass
|
|
152
|
+
finally:
|
|
153
|
+
httpd.server_close()
|
|
154
|
+
|
|
155
|
+
return httpd, url
|