memmap-replay-buffer 0.2.0__tar.gz → 0.2.2__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.
Files changed (32) hide show
  1. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/PKG-INFO +22 -1
  2. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/README.md +16 -0
  3. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/__init__.py +4 -0
  4. memmap_replay_buffer-0.2.2/memmap_replay_buffer/concat_replay_buffer.py +93 -0
  5. memmap_replay_buffer-0.2.2/memmap_replay_buffer/episode_collector.py +284 -0
  6. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend/index.html +87 -0
  7. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend.py +118 -0
  8. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/replay_buffer.py +571 -625
  9. memmap_replay_buffer-0.2.2/memmap_replay_buffer/replay_buffer_h5py.py +233 -0
  10. memmap_replay_buffer-0.2.2/memmap_replay_buffer/video.py +163 -0
  11. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/pyproject.toml +11 -2
  12. memmap_replay_buffer-0.2.2/tests/test_frontend.py +134 -0
  13. memmap_replay_buffer-0.2.2/tests/test_video.py +179 -0
  14. memmap_replay_buffer-0.2.0/memmap_replay_buffer/concat_replay_buffer.py +0 -94
  15. memmap_replay_buffer-0.2.0/memmap_replay_buffer/episode_collector.py +0 -105
  16. memmap_replay_buffer-0.2.0/memmap_replay_buffer/replay_buffer_h5py.py +0 -965
  17. memmap_replay_buffer-0.2.0/tests/test_e2e.py +0 -376
  18. memmap_replay_buffer-0.2.0/tests/test_edge_cases.py +0 -384
  19. memmap_replay_buffer-0.2.0/tests/test_episode_collector.py +0 -58
  20. memmap_replay_buffer-0.2.0/tests/test_get_all_data.py +0 -64
  21. memmap_replay_buffer-0.2.0/tests/test_lazy_init.py +0 -68
  22. memmap_replay_buffer-0.2.0/tests/test_mixed_storage.py +0 -81
  23. memmap_replay_buffer-0.2.0/tests/test_n_step.py +0 -205
  24. memmap_replay_buffer-0.2.0/tests/test_nstep_timestep_gotcha.py +0 -31
  25. memmap_replay_buffer-0.2.0/tests/test_replay_buffer.py +0 -764
  26. memmap_replay_buffer-0.2.0/tests/test_replay_buffer_h5py.py +0 -158
  27. memmap_replay_buffer-0.2.0/tests/test_scalar_flexibility.py +0 -45
  28. memmap_replay_buffer-0.2.0/tests/test_store_episode_parity.py +0 -71
  29. memmap_replay_buffer-0.2.0/tests/test_time_window.py +0 -51
  30. memmap_replay_buffer-0.2.0/tests/test_uint8_storage.py +0 -55
  31. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/.gitignore +0 -0
  32. {memmap_replay_buffer-0.2.0 → memmap_replay_buffer-0.2.2}/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memmap-replay-buffer
3
- Version: 0.2.0
3
+ Version: 0.2.2
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,22 @@ 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 an episode 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.]`.
217
+
218
+ ```python
219
+ buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
220
+ ```
221
+
222
+ `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
223
+
224
+ The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
225
+
226
+ ```bash
227
+ $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
228
+ ```
229
+
209
230
  ### Reopening, read-only, and clearing
210
231
 
211
232
  `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,22 @@ 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 an episode 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.]`.
167
+
168
+ ```python
169
+ buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
170
+ ```
171
+
172
+ `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
173
+
174
+ The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
175
+
176
+ ```bash
177
+ $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
178
+ ```
179
+
164
180
  ### Reopening, read-only, and clearing
165
181
 
166
182
  `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.
@@ -10,3 +10,7 @@ try:
10
10
  from memmap_replay_buffer.replay_buffer_h5py import ReplayBufferH5PY as ReplayBufferH5PY
11
11
  except ImportError:
12
12
  pass
13
+
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,284 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict, namedtuple
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from beartype import beartype
8
+ from numpy import ndarray
9
+ import torch
10
+ from torch import is_tensor
11
+ from torch.utils.data import DataLoader, Dataset, default_collate
12
+
13
+ from memmap_replay_buffer.replay_buffer import tree_map_to_device
14
+
15
+ # helpers
16
+
17
+ def exists(v):
18
+ return v is not None
19
+
20
+ def default(v, d):
21
+ return v if exists(v) else d
22
+
23
+ def is_group_batched(value, num_groups):
24
+ if is_tensor(value) or isinstance(value, ndarray):
25
+ return value.ndim > 0 and value.shape[0] == num_groups
26
+
27
+ if isinstance(value, (list, tuple)):
28
+ return len(value) == num_groups
29
+
30
+ return False
31
+
32
+ def stack(values, dim = 0):
33
+ if is_tensor(values[0]):
34
+ return torch.stack(values, dim = dim)
35
+
36
+ return np.stack(values, axis = dim)
37
+
38
+ # dataset
39
+
40
+ class TensorDictDataset(Dataset):
41
+ def __init__(self, data: dict[str, torch.Tensor]):
42
+ self.data = data
43
+ self.num_samples = len(next(iter(data.values())))
44
+
45
+ def __len__(self):
46
+ return self.num_samples
47
+
48
+ def __getitem__(self, index):
49
+ return {name: value[index] for name, value in self.data.items()}
50
+
51
+ # class
52
+
53
+ class EpisodeCollector:
54
+ """ accumulates a group-batched rollout step by step, then either stores it or trains on it in memory
55
+
56
+ layouts are explicit, so the time and group axes can never be silently swapped
57
+
58
+ `append` - one step, group-batched (num_groups, *shape) or (num_groups, 1, *shape)
59
+ `extend` - whole trajectories, time-major (time, num_groups, *shape), e.g. returns / advantages
60
+ `env_major` / `time_major` - read back (num_groups, time, ...) / (time, num_groups, ...)
61
+ `all_data` / `dataloader` - env-major tensors / flattened minibatches, entirely in memory
62
+ `store` - write one episode per group, then `clear`
63
+ `clear` - discard the current rollout
64
+ """
65
+
66
+ @beartype
67
+ def __init__(
68
+ self,
69
+ replay_buffer: Any, # ReplayBuffer | ReplayBufferH5PY, without a hard dependency on h5py
70
+ num_groups: int,
71
+ fieldnames: tuple[str, ...] | None = None,
72
+ meta_fieldnames: tuple[str, ...] | None = None,
73
+ strict: bool = True
74
+ ):
75
+ self.replay_buffer = replay_buffer
76
+ self.num_groups = num_groups
77
+
78
+ self.fieldnames = set(default(fieldnames, tuple(replay_buffer.fieldnames)))
79
+ self.meta_fieldnames = set(default(meta_fieldnames, tuple(replay_buffer.meta_fieldnames)))
80
+
81
+ self.shapes = replay_buffer.shapes
82
+ self.meta_shapes = replay_buffer.meta_shapes
83
+ self.strict = strict
84
+
85
+ self.clear()
86
+
87
+ def __len__(self):
88
+ return self.step_count
89
+
90
+ def clear(self):
91
+ self.episode_data = [defaultdict(list) for _ in range(self.num_groups)]
92
+ self.step_count = 0
93
+
94
+ # fields
95
+
96
+ def field_kind(self, name):
97
+ if name in self.fieldnames:
98
+ return 'data'
99
+
100
+ if name in self.meta_fieldnames:
101
+ return 'meta'
102
+
103
+ if self.strict:
104
+ raise KeyError(f'unknown field {name} - valid fields are {sorted(self.fieldnames | self.meta_fieldnames)}')
105
+
106
+ return None
107
+
108
+ def field_shape(self, name):
109
+ return self.meta_shapes[name] if name in self.meta_fieldnames else self.shapes[name]
110
+
111
+ def per_group(self, name, value):
112
+ # (num_groups, *shape) / (num_groups, 1, *shape) -> num_groups values of shape (*shape)
113
+
114
+ if isinstance(value, (list, tuple)):
115
+ return list(value)
116
+
117
+ shape = tuple(self.field_shape(name))
118
+ rest = tuple(value.shape[1:])
119
+
120
+ assert rest in (shape, (1, *shape)), f'field {name} - per-group shape {rest} should be {shape} or (1, *shape)'
121
+
122
+ if rest != shape:
123
+ value = value[:, 0]
124
+
125
+ return list(value)
126
+
127
+ # adding data
128
+
129
+ @beartype
130
+ def append(self, **data):
131
+ # one step, group-batched values
132
+
133
+ for name, value in data.items():
134
+ kind = self.field_kind(name)
135
+
136
+ if not exists(kind):
137
+ continue
138
+
139
+ if kind == 'meta':
140
+ values = [value] * self.num_groups
141
+ else:
142
+ shape = tuple(self.field_shape(name))
143
+
144
+ assert is_group_batched(value, self.num_groups), (
145
+ f'field {name} - `append` expects per-step group-batched values of shape ({self.num_groups}, *{shape}) '
146
+ f'or ({self.num_groups}, 1, *{shape}) - for whole trajectories in time-major (time, {self.num_groups}, *{shape}), use `extend`'
147
+ )
148
+
149
+ values = self.per_group(name, value)
150
+
151
+ for group_index, group_value in enumerate(values):
152
+ self.episode_data[group_index][name].append(group_value)
153
+
154
+ self.step_count += 1
155
+
156
+ @beartype
157
+ def extend(self, **trajectories):
158
+ # whole trajectories, time-major (time, num_groups, *shape)
159
+
160
+ for name, value in trajectories.items():
161
+ kind = self.field_kind(name)
162
+
163
+ if not exists(kind):
164
+ continue
165
+
166
+ assert is_tensor(value) or isinstance(value, ndarray), f'field {name} - `extend` expects a tensor / ndarray of shape (time, {self.num_groups}, *shape)'
167
+
168
+ time = value.shape[0]
169
+
170
+ assert value.ndim > 1 and value.shape[1] == self.num_groups, (
171
+ f'field {name} - `extend` expects time-major (time, {self.num_groups}, *shape), got {tuple(value.shape)}'
172
+ )
173
+
174
+ if len(self) == 0:
175
+ self.step_count = time
176
+ else:
177
+ assert time == len(self), f'field {name} - time dim {time} does not match the {len(self)} collected steps'
178
+
179
+ shape = tuple(self.field_shape(name))
180
+ assert tuple(value.shape[2:]) == shape, f'field {name} - feature shape {tuple(value.shape[2:])} does not match {shape}'
181
+
182
+ for group_index in range(self.num_groups):
183
+ self.episode_data[group_index][name].extend(list(value[:, group_index]))
184
+
185
+ # reading back
186
+
187
+ def groups(self, name):
188
+ # one value per group, i.e. a list of num_groups tensors / arrays
189
+
190
+ if self.field_kind(name) == 'meta':
191
+ return [data[name][-1] for data in self.episode_data]
192
+
193
+ return [stack(data[name], dim = 0) for data in self.episode_data]
194
+
195
+ def time_major(self, name):
196
+ # (time, num_groups, *shape) for data fields, (num_groups,) for meta fields
197
+
198
+ return stack(self.groups(name), dim = 1 if self.field_kind(name) == 'data' else 0)
199
+
200
+ def env_major(self, name):
201
+ # (num_groups, time, *shape), the layout generalized advantage estimation expects
202
+
203
+ values = self.time_major(name)
204
+
205
+ if self.field_kind(name) == 'meta':
206
+ return values
207
+
208
+ return values.swapaxes(0, 1)
209
+
210
+ # in-memory training - no storing required
211
+
212
+ def all_data(self, fields = None, device: torch.device | str | None = None):
213
+ # dict of env-major tensors, (num_groups, time, *shape), for the collected data fields
214
+
215
+ if len(self) == 0:
216
+ raise ValueError('collector is empty')
217
+
218
+ collected = tuple(name for name in self.episode_data[0] if name in self.fieldnames)
219
+
220
+ data = {name: self.env_major(name) for name in default(fields, collected)}
221
+ return tree_map_to_device(data, device)
222
+
223
+ def dataloader(
224
+ self,
225
+ batch_size,
226
+ fields = None,
227
+ filter_fields = None,
228
+ to_named_tuple = None,
229
+ shuffle = False,
230
+ device: torch.device | str | None = None,
231
+ **kwargs
232
+ ):
233
+ # minibatches flattened over (num_groups, time) for the current rollout, entirely in memory
234
+
235
+ data = {name: torch.as_tensor(value).reshape(-1, *value.shape[2:]) for name, value in self.all_data().items()}
236
+
237
+ if exists(filter_fields) and len(filter_fields) > 0:
238
+ mask = None
239
+ for name, filter_value in filter_fields.items():
240
+ field_mask = data[name] == filter_value
241
+ mask = field_mask if not exists(mask) else (mask & field_mask)
242
+
243
+ data = {name: value[mask] for name, value in data.items()}
244
+
245
+ fields = default(fields, tuple(data.keys()))
246
+ data = {name: data[name] for name in fields}
247
+
248
+ dataset = TensorDictDataset(data)
249
+
250
+ NamedTupleCls = None
251
+ if exists(to_named_tuple):
252
+ sanitized_fields = tuple(f.lstrip('_') if f.startswith('_') else f for f in to_named_tuple)
253
+ NamedTupleCls = namedtuple('Batch', sanitized_fields)
254
+
255
+ def collate_fn(data):
256
+ batch = default_collate(data)
257
+
258
+ if exists(NamedTupleCls):
259
+ for field in to_named_tuple:
260
+ if field not in batch:
261
+ raise ValueError(f'field `{field}` not found in batch. available fields: {list(batch.keys())}')
262
+
263
+ batch = NamedTupleCls(**{san: batch[orig] for orig, san in zip(to_named_tuple, sanitized_fields)})
264
+
265
+ return tree_map_to_device(batch, device)
266
+
267
+ return DataLoader(dataset, batch_size = batch_size, shuffle = shuffle, collate_fn = collate_fn, **kwargs)
268
+
269
+ # storing
270
+
271
+ @beartype
272
+ def store(self, **meta_data):
273
+ for group_index, group_data in enumerate(self.episode_data):
274
+ store_kwargs = {name: stack(values, dim = 0) if name in self.fieldnames else values[-1] for name, values in group_data.items()}
275
+
276
+ for name, value in meta_data.items():
277
+ if name not in self.meta_fieldnames:
278
+ continue
279
+
280
+ store_kwargs[name] = value[group_index] if is_group_batched(value, self.num_groups) else value
281
+
282
+ self.replay_buffer.store_episode(**store_kwargs)
283
+
284
+ self.clear()
@@ -0,0 +1,87 @@
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
+ video {
20
+ max-width: min(75vw, 1200px);
21
+ max-height: 88vh;
22
+ border-radius: 6px;
23
+ background: #000;
24
+ }
25
+ aside {
26
+ position: fixed;
27
+ top: 50%;
28
+ right: 20px;
29
+ transform: translateY(-50%);
30
+ width: 240px;
31
+ max-height: 88vh;
32
+ overflow: auto;
33
+ padding: 16px;
34
+ background: #17181b;
35
+ border: 1px solid #26282c;
36
+ border-radius: 6px;
37
+ }
38
+ aside h1 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #7fb4e6; }
39
+ aside h2 { margin: 16px 0 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a8f98; }
40
+ dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
41
+ dt { color: #8a8f98; }
42
+ dd { margin: 0; overflow-wrap: anywhere; }
43
+ #extras:empty { display: none; }
44
+ @media (max-width: 1000px) {
45
+ body { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px; }
46
+ video { max-width: 100%; }
47
+ aside { position: static; transform: none; width: 100%; max-width: 480px; }
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <video id="replay" src="/video" controls autoplay loop muted></video>
53
+ <aside>
54
+ <h1>replay buffer</h1>
55
+ <dl id="stats"></dl>
56
+ <h2>time</h2>
57
+ <dl>
58
+ <dt>frame</dt><dd id="frame">0</dd>
59
+ </dl>
60
+ <div id="extras"></div>
61
+ </aside>
62
+ <script>
63
+ const stats = __REPLAY_STATS__;
64
+ const video = document.getElementById('replay');
65
+ const extras = document.getElementById('extras');
66
+
67
+ const statsList = document.getElementById('stats');
68
+ for (const [key, value] of Object.entries(stats)) {
69
+ const dt = document.createElement('dt');
70
+ dt.textContent = key;
71
+ const dd = document.createElement('dd');
72
+ dd.textContent = value;
73
+ statsList.append(dt, dd);
74
+ }
75
+
76
+ const frameLabel = document.getElementById('frame');
77
+ const updateTime = () => {
78
+ frameLabel.textContent = `${Math.round(video.currentTime * stats.fps)} / ${stats.frames}`;
79
+ };
80
+ video.addEventListener('timeupdate', updateTime);
81
+ updateTime();
82
+
83
+ // future: sync value predictions / actions / other per-timestep elements to `video.currentTime`
84
+ window.replay = { stats, video, extras };
85
+ </script>
86
+ </body>
87
+ </html>
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ # helpers
11
+
12
+ def render_page(stats: dict):
13
+ template_path = Path(__file__).parent / 'frontend' / 'index.html'
14
+ return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(stats))
15
+
16
+ def parse_range(range_header: str | None, size: int):
17
+ if not range_header or not range_header.startswith('bytes='):
18
+ return None
19
+
20
+ start_str, _, end_str = range_header[len('bytes='):].split(',')[0].partition('-')
21
+
22
+ if not start_str:
23
+ # suffix range, e.g. bytes=-500
24
+ return max(size - int(end_str), 0), size - 1
25
+
26
+ return int(start_str), min(int(end_str), size - 1) if end_str else size - 1
27
+
28
+ # server
29
+
30
+ class ReplayBufferHandler(BaseHTTPRequestHandler):
31
+ video_path: Path
32
+ page: str
33
+
34
+ protocol_version = 'HTTP/1.1'
35
+
36
+ def log_message(self, *args):
37
+ pass
38
+
39
+ def do_GET(self):
40
+ path = urlparse(self.path).path
41
+
42
+ if path == '/':
43
+ self.send_page()
44
+ elif path == '/video':
45
+ self.send_video()
46
+ else:
47
+ self.send_error(404)
48
+
49
+ def send_page(self):
50
+ body = self.page.encode()
51
+
52
+ self.send_response(200)
53
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
54
+ self.send_header('Content-Length', str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def send_video(self):
59
+ size = self.video_path.stat().st_size
60
+ byte_range = parse_range(self.headers.get('Range'), size)
61
+
62
+ start, end = byte_range if byte_range else (0, size - 1)
63
+ is_partial = byte_range is not None
64
+
65
+ self.send_response(206 if is_partial else 200)
66
+ self.send_header('Content-Type', 'video/mp4')
67
+ self.send_header('Content-Length', str(end - start + 1))
68
+ self.send_header('Accept-Ranges', 'bytes')
69
+
70
+ if is_partial:
71
+ self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
72
+
73
+ self.end_headers()
74
+
75
+ # stream the selected slice, seeking works via repeated range requests
76
+
77
+ with open(self.video_path, 'rb') as f:
78
+ f.seek(start)
79
+ remaining = end - start + 1
80
+
81
+ while remaining > 0:
82
+ chunk = f.read(min(remaining, 1 << 20))
83
+
84
+ if not chunk:
85
+ break
86
+
87
+ self.wfile.write(chunk)
88
+ remaining -= len(chunk)
89
+
90
+ def make_server(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000):
91
+ handler = type('Handler', (ReplayBufferHandler,), dict(
92
+ video_path = Path(video_path),
93
+ page = render_page(stats)
94
+ ))
95
+
96
+ return ThreadingHTTPServer((host, port), handler)
97
+
98
+ def serve(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000, open_browser = True, block = True):
99
+ httpd = make_server(video_path, stats, host, port)
100
+ url = f'http://{host}:{httpd.server_address[1]}/'
101
+
102
+ print(f'serving replay at {url} (ctrl-c to stop)')
103
+
104
+ if open_browser:
105
+ threading.Thread(target = webbrowser.open, args = (url,), daemon = True).start()
106
+
107
+ if not block:
108
+ threading.Thread(target = httpd.serve_forever, daemon = True).start()
109
+ return httpd, url
110
+
111
+ try:
112
+ httpd.serve_forever()
113
+ except KeyboardInterrupt:
114
+ pass
115
+ finally:
116
+ httpd.server_close()
117
+
118
+ return httpd, url