nwb-video-widgets 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,155 @@
1
+ """DANDI NWB video player widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Optional
8
+
9
+ import anywidget
10
+ import traitlets
11
+ from pynwb import NWBFile
12
+
13
+ from nwb_video_widgets._utils import discover_video_series, get_video_info, get_video_timestamps
14
+
15
+ if TYPE_CHECKING:
16
+ from dandi.dandiapi import RemoteAsset
17
+
18
+
19
+ class NWBDANDIVideoPlayer(anywidget.AnyWidget):
20
+ """Display videos from a DANDI-hosted NWB file with synchronized playback.
21
+
22
+ This widget discovers ImageSeries with external_file references in the NWB
23
+ file and resolves their paths to S3 URLs via the DANDI API. An interactive
24
+ settings panel allows users to select which videos to display and choose
25
+ between Row, Column, or Grid layouts.
26
+
27
+ Parameters
28
+ ----------
29
+ asset : RemoteAsset
30
+ DANDI asset object (from dandiset.get_asset_by_path() or similar).
31
+ The dandiset_id and asset path are extracted from this object.
32
+ nwbfile : pynwb.NWBFile, optional
33
+ Pre-loaded NWB file to avoid re-loading. If not provided, the widget
34
+ will load the NWB file via streaming.
35
+
36
+ Example
37
+ -------
38
+ >>> from dandi.dandiapi import DandiAPIClient
39
+ >>> client = DandiAPIClient()
40
+ >>> dandiset = client.get_dandiset("000409", "draft")
41
+ >>> asset = dandiset.get_asset_by_path("sub-NYU-39/sub-NYU-39_ses-...nwb")
42
+ >>> widget = NWBDANDIVideoPlayer(asset=asset)
43
+ >>> display(widget)
44
+
45
+ With pre-loaded NWB file (avoids re-loading):
46
+
47
+ >>> widget = NWBDANDIVideoPlayer(
48
+ ... asset=asset,
49
+ ... nwbfile=already_loaded_nwbfile,
50
+ ... )
51
+ """
52
+
53
+ video_urls = traitlets.Dict({}).tag(sync=True)
54
+ video_timestamps = traitlets.Dict({}).tag(sync=True)
55
+ available_videos = traitlets.Dict({}).tag(sync=True)
56
+ selected_videos = traitlets.List([]).tag(sync=True)
57
+ layout_mode = traitlets.Unicode("row").tag(sync=True)
58
+ settings_open = traitlets.Bool(False).tag(sync=True)
59
+
60
+ _esm = pathlib.Path(__file__).parent / "video_widget.js"
61
+ _css = pathlib.Path(__file__).parent / "video_widget.css"
62
+
63
+ def __init__(
64
+ self,
65
+ asset: RemoteAsset,
66
+ nwbfile: Optional[NWBFile] = None,
67
+ **kwargs,
68
+ ):
69
+ # Load NWB file if not provided
70
+ if nwbfile is None:
71
+ nwbfile = self._load_nwbfile_from_dandi(asset)
72
+
73
+ video_urls = self.get_video_urls_from_dandi(nwbfile, asset)
74
+ video_timestamps = get_video_timestamps(nwbfile)
75
+ available_videos = get_video_info(nwbfile)
76
+
77
+ # Start with no videos selected to avoid initial buffering
78
+ super().__init__(
79
+ video_urls=video_urls,
80
+ video_timestamps=video_timestamps,
81
+ available_videos=available_videos,
82
+ selected_videos=[],
83
+ layout_mode="grid",
84
+ settings_open=True,
85
+ **kwargs,
86
+ )
87
+
88
+ @staticmethod
89
+ def _load_nwbfile_from_dandi(asset: RemoteAsset) -> NWBFile:
90
+ """Load an NWB file from DANDI via streaming.
91
+
92
+ Parameters
93
+ ----------
94
+ asset : RemoteAsset
95
+ DANDI asset object
96
+
97
+ Returns
98
+ -------
99
+ NWBFile
100
+ Loaded NWB file
101
+ """
102
+ import h5py
103
+ import remfile
104
+ from pynwb import NWBHDF5IO
105
+
106
+ s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)
107
+
108
+ remote_file = remfile.File(s3_url)
109
+ h5_file = h5py.File(remote_file, "r")
110
+ io = NWBHDF5IO(file=h5_file, load_namespaces=True)
111
+ return io.read()
112
+
113
+ @staticmethod
114
+ def get_video_urls_from_dandi(
115
+ nwbfile: NWBFile,
116
+ asset: RemoteAsset,
117
+ ) -> dict[str, str]:
118
+ """Extract video S3 URLs from NWB file using DANDI API.
119
+
120
+ Videos in NWB files are stored as ImageSeries with external_file paths.
121
+ This function finds all ImageSeries with external files and resolves
122
+ their relative paths to full S3 URLs using the DANDI API.
123
+
124
+ Parameters
125
+ ----------
126
+ nwbfile : pynwb.NWBFile
127
+ NWB file containing video ImageSeries in acquisition
128
+ asset : RemoteAsset
129
+ DANDI asset object for the NWB file
130
+
131
+ Returns
132
+ -------
133
+ dict[str, str]
134
+ Mapping of video names to S3 URLs
135
+ """
136
+ from dandi.dandiapi import DandiAPIClient
137
+
138
+ client = DandiAPIClient()
139
+ dandiset = client.get_dandiset(asset.dandiset_id, asset.version_id)
140
+
141
+ nwb_parent = Path(asset.path).parent
142
+ video_series = discover_video_series(nwbfile)
143
+ video_urls = {}
144
+
145
+ for name, series in video_series.items():
146
+ relative_path = series.external_file[0].lstrip("./")
147
+ full_path = str(nwb_parent / relative_path)
148
+
149
+ video_asset = dandiset.get_asset_by_path(full_path)
150
+ if video_asset is not None:
151
+ video_urls[name] = video_asset.get_content_url(
152
+ follow_redirects=1, strip_query=True
153
+ )
154
+
155
+ return video_urls
@@ -0,0 +1,334 @@
1
+ """Local NWB pose estimation video overlay widget."""
2
+
3
+ import pathlib
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import anywidget
8
+ import matplotlib.colors as mcolors
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import traitlets
12
+ from pynwb import NWBFile
13
+
14
+ from nwb_video_widgets._utils import (
15
+ discover_pose_estimation_cameras,
16
+ discover_video_series,
17
+ get_pose_estimation_info,
18
+ start_video_server,
19
+ )
20
+
21
+
22
+ class NWBLocalPoseEstimationWidget(anywidget.AnyWidget):
23
+ """Video player with pose estimation overlay for local NWB files.
24
+
25
+ Overlays DeepLabCut keypoints on streaming video with support for
26
+ camera selection via a settings panel.
27
+
28
+ This widget discovers PoseEstimation containers in processing['pose_estimation']
29
+ and resolves video paths relative to the NWB file location. An interactive
30
+ settings panel allows users to select which camera to display.
31
+
32
+ Supports two common NWB patterns:
33
+ 1. Single file: both videos and pose estimation in same NWB file
34
+ 2. Split files: videos in raw NWB file, pose estimation in processed file
35
+
36
+ Parameters
37
+ ----------
38
+ nwbfile : pynwb.NWBFile
39
+ NWB file containing pose estimation in processing['pose_estimation'].
40
+ Must have been loaded from disk.
41
+ video_nwbfile : pynwb.NWBFile, optional
42
+ NWB file containing video ImageSeries in acquisition. If not provided,
43
+ videos are assumed to be in `nwbfile`. Use this when videos are in a
44
+ separate raw file from the processed pose data.
45
+ keypoint_colors : str or dict, default 'tab10'
46
+ Either a matplotlib colormap name (e.g., 'tab10', 'Set1', 'Paired') for
47
+ automatic color assignment, or a dict mapping keypoint names to hex colors
48
+ (e.g., {'LeftPaw': '#FF0000', 'RightPaw': '#00FF00'}).
49
+ default_camera : str, optional
50
+ Camera to display initially. Falls back to first available if not found.
51
+
52
+ Example
53
+ -------
54
+ Single file (videos + pose in same file):
55
+
56
+ >>> from pynwb import NWBHDF5IO
57
+ >>> with NWBHDF5IO("experiment.nwb", "r") as io:
58
+ ... nwbfile = io.read()
59
+ ... widget = NWBLocalPoseEstimationWidget(nwbfile)
60
+ ... display(widget)
61
+
62
+ Split files (videos in raw, pose in processed):
63
+
64
+ >>> io_raw = NWBHDF5IO("raw.nwb", "r")
65
+ >>> io_processed = NWBHDF5IO("processed.nwb", "r")
66
+ >>> nwbfile_raw = io_raw.read()
67
+ >>> nwbfile_processed = io_processed.read()
68
+ >>> widget = NWBLocalPoseEstimationWidget(
69
+ ... nwbfile=nwbfile_processed,
70
+ ... video_nwbfile=nwbfile_raw,
71
+ ... )
72
+ >>> display(widget)
73
+
74
+ Raises
75
+ ------
76
+ ValueError
77
+ If the NWB file was not loaded from disk (read_io is None) or
78
+ if no cameras have both pose data and video.
79
+ """
80
+
81
+ selected_camera = traitlets.Unicode("").tag(sync=True)
82
+ available_cameras = traitlets.List([]).tag(sync=True)
83
+ available_cameras_info = traitlets.Dict({}).tag(sync=True)
84
+
85
+ # Video selection - users explicitly match cameras to videos
86
+ available_videos = traitlets.List([]).tag(sync=True)
87
+ available_videos_info = traitlets.Dict({}).tag(sync=True)
88
+ video_name_to_url = traitlets.Dict({}).tag(sync=True) # Video name -> URL mapping
89
+ camera_to_video = traitlets.Dict({}).tag(sync=True) # Camera -> video name mapping
90
+
91
+ settings_open = traitlets.Bool(True).tag(sync=True)
92
+
93
+ # Pose data for cameras - loaded lazily when selected
94
+ all_camera_data = traitlets.Dict({}).tag(sync=True)
95
+
96
+ # Loading state for progress indicator
97
+ loading = traitlets.Bool(False).tag(sync=True)
98
+
99
+ show_labels = traitlets.Bool(True).tag(sync=True)
100
+ visible_keypoints = traitlets.Dict({}).tag(sync=True)
101
+
102
+ _esm = pathlib.Path(__file__).parent / "pose_widget.js"
103
+ _css = pathlib.Path(__file__).parent / "pose_widget.css"
104
+
105
+ def __init__(
106
+ self,
107
+ nwbfile: NWBFile,
108
+ video_nwbfile: Optional[NWBFile] = None,
109
+ keypoint_colors: str | dict[str, str] = "tab10",
110
+ default_camera: Optional[str] = None,
111
+ **kwargs,
112
+ ):
113
+ # Use video_nwbfile for videos if provided, otherwise use nwbfile
114
+ video_source = video_nwbfile if video_nwbfile is not None else nwbfile
115
+
116
+ # Compute video URLs from local files
117
+ video_urls = self._get_video_urls_from_local(video_source)
118
+
119
+ # Parse keypoint_colors
120
+ if isinstance(keypoint_colors, str):
121
+ colormap_name = keypoint_colors
122
+ custom_colors = {}
123
+ else:
124
+ colormap_name = "tab10"
125
+ custom_colors = keypoint_colors
126
+
127
+ # Get pose estimation container
128
+ if "pose_estimation" not in nwbfile.processing:
129
+ raise ValueError("NWB file does not contain pose_estimation processing module")
130
+ pose_estimation = nwbfile.processing["pose_estimation"]
131
+
132
+ # Get all PoseEstimation containers (excludes Skeletons and other metadata)
133
+ pose_containers = discover_pose_estimation_cameras(nwbfile)
134
+ available_cameras = list(pose_containers.keys())
135
+
136
+ # Get camera info for settings panel display
137
+ available_cameras_info = get_pose_estimation_info(nwbfile)
138
+
139
+ # Get ALL available videos (sorted alphabetically)
140
+ available_videos = sorted(video_urls.keys())
141
+ available_videos_info = self._get_video_info(video_source)
142
+
143
+ # Video name to URL mapping (sent to JS for URL resolution)
144
+ video_name_to_url = video_urls
145
+
146
+ # Start with empty mapping - users explicitly select videos
147
+ camera_to_video = {}
148
+
149
+ # Select default camera - start with empty to show settings
150
+ if default_camera and default_camera in available_cameras:
151
+ selected_camera = default_camera
152
+ else:
153
+ selected_camera = ""
154
+
155
+ # Store references for lazy loading (not synced to JS)
156
+ self._pose_estimation = pose_estimation
157
+ self._cmap = plt.get_cmap(colormap_name)
158
+ self._custom_colors = custom_colors
159
+
160
+ super().__init__(
161
+ selected_camera=selected_camera,
162
+ available_cameras=available_cameras,
163
+ available_cameras_info=available_cameras_info,
164
+ available_videos=available_videos,
165
+ available_videos_info=available_videos_info,
166
+ video_name_to_url=video_name_to_url,
167
+ camera_to_video=camera_to_video,
168
+ all_camera_data={}, # Start empty, load lazily
169
+ visible_keypoints={}, # Populated as cameras are loaded
170
+ settings_open=True,
171
+ **kwargs,
172
+ )
173
+
174
+ @traitlets.observe("selected_camera")
175
+ def _on_camera_selected(self, change):
176
+ """Load pose data lazily when a camera is selected."""
177
+ camera_name = change["new"]
178
+ if not camera_name or camera_name in self.all_camera_data:
179
+ return # Already loaded or no camera selected
180
+
181
+ # Signal loading start
182
+ self.loading = True
183
+
184
+ try:
185
+ # Load pose data for this camera
186
+ camera_data = self._load_camera_pose_data(
187
+ self._pose_estimation, camera_name, self._cmap, self._custom_colors
188
+ )
189
+
190
+ # Update all_camera_data (must create new dict for traitlets to detect change)
191
+ self.all_camera_data = {**self.all_camera_data, camera_name: camera_data}
192
+
193
+ # Add any new keypoints to visible_keypoints
194
+ new_keypoints = {**self.visible_keypoints}
195
+ for name in camera_data["keypoint_metadata"].keys():
196
+ if name not in new_keypoints:
197
+ new_keypoints[name] = True
198
+ if new_keypoints != self.visible_keypoints:
199
+ self.visible_keypoints = new_keypoints
200
+ finally:
201
+ # Signal loading complete
202
+ self.loading = False
203
+
204
+ @staticmethod
205
+ def _get_video_info(nwbfile: NWBFile) -> dict[str, dict]:
206
+ """Get metadata for all video series."""
207
+ video_series = discover_video_series(nwbfile)
208
+ info = {}
209
+
210
+ for name, series in video_series.items():
211
+ timestamps = None
212
+ if series.timestamps is not None:
213
+ timestamps = series.timestamps[:]
214
+ elif series.starting_time is not None and series.rate is not None:
215
+ n_frames = series.data.shape[0] if hasattr(series.data, "shape") else 0
216
+ timestamps = np.arange(n_frames) / series.rate + series.starting_time
217
+
218
+ if timestamps is not None and len(timestamps) > 0:
219
+ info[name] = {
220
+ "start": float(timestamps[0]),
221
+ "end": float(timestamps[-1]),
222
+ "frames": len(timestamps),
223
+ }
224
+ else:
225
+ info[name] = {"start": 0, "end": 0, "frames": 0}
226
+
227
+ return info
228
+
229
+ @traitlets.observe("camera_to_video")
230
+ def _on_camera_to_video_changed(self, change):
231
+ """Update video URL when user changes camera-to-video mapping."""
232
+ # The camera_to_video dict now stores video URLs directly
233
+ # This observer can be used for any side effects needed
234
+ pass
235
+
236
+ @staticmethod
237
+ def _get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]:
238
+ """Extract video file URLs from a local NWB file.
239
+
240
+ Resolves external_file paths relative to the NWB file location and
241
+ serves them via a local HTTP server for browser playback.
242
+ """
243
+ if nwbfile.read_io is None or not hasattr(nwbfile.read_io, "source"):
244
+ raise ValueError(
245
+ "NWB file must be loaded from disk to resolve video paths. "
246
+ "The nwbfile.read_io attribute is None."
247
+ )
248
+
249
+ nwbfile_path = Path(nwbfile.read_io.source)
250
+ base_dir = nwbfile_path.parent
251
+ video_series = discover_video_series(nwbfile)
252
+ video_urls = {}
253
+
254
+ if not video_series:
255
+ return video_urls
256
+
257
+ # Collect all video directories and start servers
258
+ video_dirs: set[Path] = set()
259
+ for series in video_series.values():
260
+ relative_path = series.external_file[0].lstrip("./")
261
+ video_path = (base_dir / relative_path).resolve()
262
+ video_dirs.add(video_path.parent)
263
+
264
+ # Start servers for each unique directory
265
+ dir_to_port: dict[Path, int] = {}
266
+ for video_dir in video_dirs:
267
+ port = start_video_server(video_dir)
268
+ dir_to_port[video_dir] = port
269
+
270
+ # Build URLs using the local HTTP server
271
+ for name, series in video_series.items():
272
+ relative_path = series.external_file[0].lstrip("./")
273
+ video_path = (base_dir / relative_path).resolve()
274
+ video_dir = video_path.parent
275
+ port = dir_to_port[video_dir]
276
+ video_urls[name] = f"http://127.0.0.1:{port}/{video_path.name}"
277
+
278
+ return video_urls
279
+
280
+ @staticmethod
281
+ def _load_camera_pose_data(
282
+ pose_estimation, camera_name: str, cmap, custom_colors: dict
283
+ ) -> dict:
284
+ """Load pose data for a single camera.
285
+
286
+ Returns a dict with:
287
+ - keypoint_metadata: {name: {color, label}}
288
+ - pose_coordinates: {name: [[x, y], ...]} as JSON-serializable lists
289
+ - timestamps: [t0, t1, ...] as JSON-serializable list
290
+ """
291
+ camera_pose = pose_estimation[camera_name]
292
+
293
+ keypoint_names = list(camera_pose.pose_estimation_series.keys())
294
+ n_kp = len(keypoint_names)
295
+
296
+ metadata = {}
297
+ coordinates = {}
298
+ timestamps = None
299
+
300
+ for index, (series_name, series) in enumerate(
301
+ camera_pose.pose_estimation_series.items()
302
+ ):
303
+ short_name = series_name.replace("PoseEstimationSeries", "")
304
+
305
+ # Get coordinates - iterate to build list without memory duplication
306
+ data = series.data[:]
307
+ coords_list = []
308
+ for x, y in data:
309
+ if np.isnan(x) or np.isnan(y):
310
+ coords_list.append(None)
311
+ else:
312
+ coords_list.append([float(x), float(y)])
313
+ coordinates[short_name] = coords_list
314
+
315
+ if timestamps is None:
316
+ timestamps = series.timestamps[:].tolist()
317
+
318
+ # Assign color from custom dict or colormap
319
+ if short_name in custom_colors:
320
+ color = custom_colors[short_name]
321
+ else:
322
+ if hasattr(cmap, "N") and cmap.N < 256:
323
+ rgba = cmap(index % cmap.N)
324
+ else:
325
+ rgba = cmap(index / max(n_kp - 1, 1))
326
+ color = mcolors.to_hex(rgba)
327
+
328
+ metadata[short_name] = {"color": color, "label": short_name}
329
+
330
+ return {
331
+ "keypoint_metadata": metadata,
332
+ "pose_coordinates": coordinates,
333
+ "timestamps": timestamps,
334
+ }
@@ -0,0 +1,130 @@
1
+ """Local NWB video player widget."""
2
+
3
+ import pathlib
4
+ from pathlib import Path
5
+
6
+ import anywidget
7
+ import traitlets
8
+ from pynwb import NWBFile
9
+
10
+ from nwb_video_widgets._utils import (
11
+ discover_video_series,
12
+ get_video_info,
13
+ get_video_timestamps,
14
+ start_video_server,
15
+ )
16
+
17
+
18
+ class NWBLocalVideoPlayer(anywidget.AnyWidget):
19
+ """Display local videos from an NWB file with synchronized playback.
20
+
21
+ This widget discovers ImageSeries with external_file references in the NWB
22
+ file and resolves their paths relative to the NWB file location. An interactive
23
+ settings panel allows users to select which videos to display and choose
24
+ between Row, Column, or Grid layouts.
25
+
26
+ Parameters
27
+ ----------
28
+ nwbfile : pynwb.NWBFile
29
+ NWB file containing video ImageSeries in acquisition. Must have been
30
+ loaded from disk (i.e., nwbfile.read_io must not be None).
31
+
32
+ Example
33
+ -------
34
+ >>> from pynwb import NWBHDF5IO
35
+ >>> with NWBHDF5IO("experiment.nwb", "r") as io:
36
+ ... nwbfile = io.read()
37
+ ... widget = NWBLocalVideoPlayer(nwbfile)
38
+ ... display(widget)
39
+
40
+ Raises
41
+ ------
42
+ ValueError
43
+ If the NWB file was not loaded from disk (read_io is None)
44
+ """
45
+
46
+ video_urls = traitlets.Dict({}).tag(sync=True)
47
+ video_timestamps = traitlets.Dict({}).tag(sync=True)
48
+ available_videos = traitlets.Dict({}).tag(sync=True)
49
+ selected_videos = traitlets.List([]).tag(sync=True)
50
+ layout_mode = traitlets.Unicode("row").tag(sync=True)
51
+ settings_open = traitlets.Bool(False).tag(sync=True)
52
+
53
+ _esm = pathlib.Path(__file__).parent / "video_widget.js"
54
+ _css = pathlib.Path(__file__).parent / "video_widget.css"
55
+
56
+ def __init__(
57
+ self,
58
+ nwbfile: NWBFile,
59
+ **kwargs,
60
+ ):
61
+ video_urls = self.get_video_urls_from_local(nwbfile)
62
+ video_timestamps = get_video_timestamps(nwbfile)
63
+ available_videos = get_video_info(nwbfile)
64
+
65
+ # Start with no videos selected to avoid initial buffering
66
+ super().__init__(
67
+ video_urls=video_urls,
68
+ video_timestamps=video_timestamps,
69
+ available_videos=available_videos,
70
+ selected_videos=[],
71
+ layout_mode="grid",
72
+ settings_open=True,
73
+ **kwargs,
74
+ )
75
+
76
+ @staticmethod
77
+ def get_video_urls_from_local(nwbfile: NWBFile) -> dict[str, str]:
78
+ """Extract video file URLs from a local NWB file.
79
+
80
+ Resolves external_file paths relative to the NWB file location and
81
+ serves them via a local HTTP server for browser playback.
82
+
83
+ Parameters
84
+ ----------
85
+ nwbfile : pynwb.NWBFile
86
+ NWB file containing video ImageSeries in acquisition
87
+
88
+ Returns
89
+ -------
90
+ dict[str, str]
91
+ Mapping of video names to HTTP URLs served locally
92
+
93
+ Raises
94
+ ------
95
+ ValueError
96
+ If the NWB file was not loaded from disk
97
+ """
98
+ if nwbfile.read_io is None or not hasattr(nwbfile.read_io, "source"):
99
+ raise ValueError(
100
+ "NWB file must be loaded from disk to resolve video paths. "
101
+ "The nwbfile.read_io attribute is None."
102
+ )
103
+
104
+ nwbfile_path = Path(nwbfile.read_io.source)
105
+ base_dir = nwbfile_path.parent
106
+ video_series = discover_video_series(nwbfile)
107
+ video_urls = {}
108
+
109
+ # Collect all video directories and start servers
110
+ video_dirs: set[Path] = set()
111
+ for series in video_series.values():
112
+ relative_path = series.external_file[0].lstrip("./")
113
+ video_path = (base_dir / relative_path).resolve()
114
+ video_dirs.add(video_path.parent)
115
+
116
+ # Start servers for each unique directory
117
+ dir_to_port: dict[Path, int] = {}
118
+ for video_dir in video_dirs:
119
+ port = start_video_server(video_dir)
120
+ dir_to_port[video_dir] = port
121
+
122
+ # Build URLs using the local HTTP server
123
+ for name, series in video_series.items():
124
+ relative_path = series.external_file[0].lstrip("./")
125
+ video_path = (base_dir / relative_path).resolve()
126
+ video_dir = video_path.parent
127
+ port = dir_to_port[video_dir]
128
+ video_urls[name] = f"http://127.0.0.1:{port}/{video_path.name}"
129
+
130
+ return video_urls