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,566 @@
1
+ /**
2
+ * Multi-camera video player widget for synchronized playback.
3
+ * Displays videos in a configurable grid layout with unified controls.
4
+ *
5
+ * @typedef {Object.<string, string>} VideoUrls - Mapping of video names to URLs
6
+ * @typedef {string[][]} GridLayout - 2D array defining video grid (rows x cols)
7
+ */
8
+
9
+ /**
10
+ * Format seconds as MM:SS.ms string for session time display.
11
+ * @param {number} seconds
12
+ * @returns {string}
13
+ */
14
+ function formatTime(seconds) {
15
+ const mins = Math.floor(seconds / 60);
16
+ const secs = Math.floor(seconds % 60);
17
+ const ms = Math.floor((seconds % 1) * 10);
18
+ return mins + ":" + secs.toString().padStart(2, "0") + "." + ms;
19
+ }
20
+
21
+ /**
22
+ * Create an SVG icon element.
23
+ * @param {"play" | "pause" | "settings" | "warning"} type - Icon type
24
+ * @returns {SVGElement}
25
+ */
26
+ function createIcon(type) {
27
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
28
+ svg.setAttribute("width", "16");
29
+ svg.setAttribute("height", "16");
30
+ svg.setAttribute("viewBox", "0 0 24 24");
31
+ svg.setAttribute("fill", "currentColor");
32
+
33
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
34
+ if (type === "play") {
35
+ path.setAttribute("d", "M8 5v14l11-7z");
36
+ } else if (type === "pause") {
37
+ path.setAttribute("d", "M6 19h4V5H6v14zm8-14v14h4V5h-4z");
38
+ } else if (type === "settings") {
39
+ path.setAttribute(
40
+ "d",
41
+ "M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
42
+ );
43
+ } else if (type === "warning") {
44
+ path.setAttribute(
45
+ "d",
46
+ "M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"
47
+ );
48
+ }
49
+ svg.appendChild(path);
50
+ return svg;
51
+ }
52
+
53
+ /**
54
+ * Check if two videos are compatible for synchronized playback.
55
+ * Videos are compatible if their start times are within tolerance and they have overlapping time ranges.
56
+ * @param {Object} infoA - Video info {start, end, frames}
57
+ * @param {Object} infoB - Video info {start, end, frames}
58
+ * @param {number} tolerance - Maximum allowed difference in start times (default 1.0 second)
59
+ * @returns {boolean}
60
+ */
61
+ function areCompatible(infoA, infoB, tolerance = 1.0) {
62
+ const startDiff = Math.abs(infoA.start - infoB.start);
63
+ if (startDiff > tolerance) {
64
+ return false;
65
+ }
66
+ // Check if there's overlap
67
+ const overlapStart = Math.max(infoA.start, infoB.start);
68
+ const overlapEnd = Math.min(infoA.end, infoB.end);
69
+ return overlapEnd > overlapStart;
70
+ }
71
+
72
+ /**
73
+ * Calculate grid dimensions based on layout mode and number of videos.
74
+ * @param {string} layoutMode - "row", "column", or "grid"
75
+ * @param {number} count - Number of videos
76
+ * @returns {{rows: number, cols: number}}
77
+ */
78
+ function calculateGridDimensions(layoutMode, count) {
79
+ if (count === 0) return { rows: 0, cols: 0 };
80
+
81
+ if (layoutMode === "row") {
82
+ return { rows: 1, cols: count };
83
+ } else if (layoutMode === "column") {
84
+ return { rows: count, cols: 1 };
85
+ } else {
86
+ // Grid mode: square-ish layout
87
+ const cols = Math.ceil(Math.sqrt(count));
88
+ const rows = Math.ceil(count / cols);
89
+ return { rows, cols };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Render the multi-video player widget.
95
+ *
96
+ * This is the entry point called by anywidget when the widget is displayed.
97
+ *
98
+ * @param {Object} context - Provided by anywidget
99
+ * @param {Object} context.model - Proxy to Python traitlets. Use model.get('name')
100
+ * to read synced traits and model.set('name', value) + model.save_changes()
101
+ * to update them. Listen for changes with model.on('change:name', callback).
102
+ * @param {HTMLElement} context.el - The DOM element where the widget should render.
103
+ * Append all UI elements to this container.
104
+ */
105
+ function render({ model, el }) {
106
+ // Root wrapper with scoped class
107
+ const wrapper = document.createElement("div");
108
+ wrapper.classList.add("video-widget");
109
+
110
+ // Control bar
111
+ const controls = document.createElement("div");
112
+ controls.classList.add("video-widget__controls");
113
+
114
+ const playPauseBtn = document.createElement("button");
115
+ playPauseBtn.classList.add("video-widget__button");
116
+ playPauseBtn.appendChild(createIcon("play"));
117
+
118
+ const settingsBtn = document.createElement("button");
119
+ settingsBtn.classList.add("video-widget__button", "video-widget__settings-btn");
120
+ settingsBtn.appendChild(createIcon("settings"));
121
+ settingsBtn.title = "Video Settings";
122
+
123
+ const seekBar = document.createElement("input");
124
+ seekBar.type = "range";
125
+ seekBar.min = 0;
126
+ seekBar.max = 100;
127
+ seekBar.value = 0;
128
+ seekBar.classList.add("video-widget__seekbar");
129
+
130
+ const timeLabel = document.createElement("span");
131
+ timeLabel.textContent = "0:00.0 / 0:00.0";
132
+ timeLabel.classList.add("video-widget__time-label");
133
+
134
+ controls.appendChild(playPauseBtn);
135
+ controls.appendChild(settingsBtn);
136
+ controls.appendChild(seekBar);
137
+ controls.appendChild(timeLabel);
138
+
139
+ // Settings panel (collapsible)
140
+ const settingsPanel = document.createElement("div");
141
+ settingsPanel.classList.add("video-widget__settings-panel");
142
+
143
+ const settingsPanelHeader = document.createElement("div");
144
+ settingsPanelHeader.classList.add("video-widget__settings-header");
145
+
146
+ const settingsTitle = document.createElement("span");
147
+ settingsTitle.classList.add("video-widget__settings-title");
148
+ settingsTitle.textContent = "Settings";
149
+
150
+ const closeBtn = document.createElement("button");
151
+ closeBtn.classList.add("video-widget__close-btn");
152
+ closeBtn.textContent = "Close";
153
+ closeBtn.addEventListener("click", () => {
154
+ model.set("settings_open", false);
155
+ model.save_changes();
156
+ });
157
+
158
+ settingsPanelHeader.appendChild(settingsTitle);
159
+ settingsPanelHeader.appendChild(closeBtn);
160
+ settingsPanel.appendChild(settingsPanelHeader);
161
+
162
+ const videoSelectionSection = document.createElement("div");
163
+ videoSelectionSection.classList.add("video-widget__video-selection-section");
164
+
165
+ const videoSelectionTitle = document.createElement("span");
166
+ videoSelectionTitle.classList.add("video-widget__section-title");
167
+ videoSelectionTitle.textContent = "Video Selection";
168
+ videoSelectionSection.appendChild(videoSelectionTitle);
169
+
170
+ const videoSelectionHint = document.createElement("p");
171
+ videoSelectionHint.classList.add("video-widget__section-hint");
172
+ videoSelectionHint.textContent = "Videos are displayed in selection order and sync to the first selected.";
173
+ videoSelectionSection.appendChild(videoSelectionHint);
174
+
175
+ const videoList = document.createElement("div");
176
+ videoList.classList.add("video-widget__video-list");
177
+ videoSelectionSection.appendChild(videoList);
178
+
179
+ settingsPanel.appendChild(videoSelectionSection);
180
+
181
+ const layoutSection = document.createElement("div");
182
+ layoutSection.classList.add("video-widget__layout-section");
183
+
184
+ const layoutTitle = document.createElement("span");
185
+ layoutTitle.classList.add("video-widget__section-title");
186
+ layoutTitle.textContent = "Video Grid Layout";
187
+ layoutSection.appendChild(layoutTitle);
188
+
189
+ const layoutOptionsContainer = document.createElement("div");
190
+ layoutOptionsContainer.classList.add("video-widget__layout-options");
191
+
192
+ const layoutModes = ["row", "column", "grid"];
193
+ layoutModes.forEach((option) => {
194
+ const radioContainer = document.createElement("label");
195
+ radioContainer.classList.add("video-widget__layout-option");
196
+
197
+ const radio = document.createElement("input");
198
+ radio.type = "radio";
199
+ radio.name = "layout-mode";
200
+ radio.value = option;
201
+ radio.checked = option === model.get("layout_mode");
202
+ radio.addEventListener("change", () => {
203
+ if (radio.checked) {
204
+ model.set("layout_mode", option);
205
+ model.save_changes();
206
+ }
207
+ });
208
+
209
+ const labelText = document.createElement("span");
210
+ labelText.textContent = option.charAt(0).toUpperCase() + option.slice(1);
211
+
212
+ radioContainer.appendChild(radio);
213
+ radioContainer.appendChild(labelText);
214
+ layoutOptionsContainer.appendChild(radioContainer);
215
+ });
216
+
217
+ layoutSection.appendChild(layoutOptionsContainer);
218
+ settingsPanel.appendChild(layoutSection);
219
+
220
+ // Grid container - using CSS Grid for proper 2D layout
221
+ const gridContainer = document.createElement("div");
222
+ gridContainer.classList.add("video-widget__grid");
223
+
224
+ /** @type {HTMLVideoElement[]} */
225
+ let videos = [];
226
+ /** @type {HTMLDivElement[]} */
227
+ let videoContainers = [];
228
+ let isPlaying = false;
229
+ let syncAnimationId = null;
230
+
231
+ /**
232
+ * Update the settings panel with available videos and their compatibility status.
233
+ */
234
+ function updateSettingsPanel() {
235
+ const availableVideos = model.get("available_videos");
236
+ const selectedVideos = model.get("selected_videos") || [];
237
+ const layoutMode = model.get("layout_mode") || "row";
238
+ const settingsOpen = model.get("settings_open");
239
+
240
+ // Toggle panel visibility
241
+ if (settingsOpen) {
242
+ settingsPanel.classList.add("video-widget__settings-panel--open");
243
+ } else {
244
+ settingsPanel.classList.remove("video-widget__settings-panel--open");
245
+ }
246
+
247
+ // Update layout radio buttons
248
+ const radios = layoutSection.querySelectorAll('input[type="radio"]');
249
+ radios.forEach((radio) => {
250
+ radio.checked = radio.value === layoutMode;
251
+ });
252
+
253
+ // Clear and rebuild video list
254
+ videoList.innerHTML = "";
255
+
256
+ const videoNames = Object.keys(availableVideos);
257
+ if (videoNames.length === 0) {
258
+ const emptyMsg = document.createElement("p");
259
+ emptyMsg.classList.add("video-widget__empty-msg");
260
+ emptyMsg.textContent = "No videos available.";
261
+ videoList.appendChild(emptyMsg);
262
+ return;
263
+ }
264
+
265
+ videoNames.forEach((name) => {
266
+ const info = availableVideos[name];
267
+ const isSelected = selectedVideos.includes(name);
268
+
269
+ // Check compatibility with currently selected videos
270
+ let isCompatible = true;
271
+ if (!isSelected && selectedVideos.length > 0) {
272
+ isCompatible = selectedVideos.every((selectedName) => {
273
+ const selectedInfo = availableVideos[selectedName];
274
+ return areCompatible(info, selectedInfo);
275
+ });
276
+ }
277
+
278
+ const videoItem = document.createElement("div");
279
+ videoItem.classList.add("video-widget__video-item");
280
+ if (!isCompatible) {
281
+ videoItem.classList.add("video-widget__video-item--incompatible");
282
+ }
283
+
284
+ const checkbox = document.createElement("input");
285
+ checkbox.type = "checkbox";
286
+ checkbox.id = "video-" + name;
287
+ checkbox.checked = isSelected;
288
+ checkbox.disabled = !isCompatible && !isSelected;
289
+ checkbox.addEventListener("change", () => {
290
+ const currentSelected = [...(model.get("selected_videos") || [])];
291
+ if (checkbox.checked) {
292
+ if (!currentSelected.includes(name)) {
293
+ currentSelected.push(name);
294
+ }
295
+ } else {
296
+ const index = currentSelected.indexOf(name);
297
+ if (index > -1) {
298
+ currentSelected.splice(index, 1);
299
+ }
300
+ }
301
+ model.set("selected_videos", currentSelected);
302
+ model.save_changes();
303
+ });
304
+
305
+ const label = document.createElement("label");
306
+ label.htmlFor = "video-" + name;
307
+ label.classList.add("video-widget__video-item-label");
308
+ label.textContent = name;
309
+
310
+ const timeRange = document.createElement("span");
311
+ timeRange.classList.add("video-widget__video-item-time");
312
+ timeRange.textContent = formatTime(info.start) + " - " + formatTime(info.end);
313
+
314
+ videoItem.appendChild(checkbox);
315
+ videoItem.appendChild(label);
316
+ videoItem.appendChild(timeRange);
317
+
318
+ if (!isCompatible) {
319
+ const warningIcon = document.createElement("span");
320
+ warningIcon.classList.add("video-widget__warning-icon");
321
+ warningIcon.title = "Incompatible time range with selected videos";
322
+ warningIcon.appendChild(createIcon("warning"));
323
+ videoItem.appendChild(warningIcon);
324
+ }
325
+
326
+ videoList.appendChild(videoItem);
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Synchronize all videos to the master (first) video.
332
+ * Corrects drift that occurs due to network latency and buffering differences.
333
+ */
334
+ function syncVideos() {
335
+ if (videos.length < 2 || !isPlaying) {
336
+ return;
337
+ }
338
+
339
+ const masterTime = videos[0].currentTime;
340
+ for (let i = 1; i < videos.length; i++) {
341
+ const drift = videos[i].currentTime - masterTime;
342
+ // Correct if drift exceeds 100ms
343
+ if (Math.abs(drift) > 0.1) {
344
+ videos[i].currentTime = masterTime;
345
+ }
346
+ }
347
+
348
+ syncAnimationId = requestAnimationFrame(syncVideos);
349
+ }
350
+
351
+ /**
352
+ * Update play/pause button content.
353
+ * @param {boolean} playing - Current play state
354
+ */
355
+ function updatePlayPauseButton(playing) {
356
+ playPauseBtn.innerHTML = "";
357
+ playPauseBtn.appendChild(createIcon(playing ? "pause" : "play"));
358
+ }
359
+
360
+ /**
361
+ * Get the session time offset for the first selected video.
362
+ * This is the starting timestamp from the NWB file.
363
+ */
364
+ function getSessionTimeOffset() {
365
+ const timestamps = model.get("video_timestamps");
366
+ const selectedVideos = model.get("selected_videos") || [];
367
+ // Find the first selected video that has timestamps
368
+ for (const name of selectedVideos) {
369
+ if (timestamps[name] && timestamps[name].length > 0) {
370
+ return timestamps[name][0];
371
+ }
372
+ }
373
+ return 0;
374
+ }
375
+
376
+ /**
377
+ * Get the session end time (last timestamp) for the first selected video.
378
+ */
379
+ function getSessionEndTime() {
380
+ const timestamps = model.get("video_timestamps");
381
+ const selectedVideos = model.get("selected_videos") || [];
382
+ for (const name of selectedVideos) {
383
+ if (timestamps[name] && timestamps[name].length > 1) {
384
+ return timestamps[name][timestamps[name].length - 1];
385
+ }
386
+ }
387
+ return null; // Will fall back to video duration
388
+ }
389
+
390
+ function updateVideos() {
391
+ gridContainer.innerHTML = "";
392
+ videos = [];
393
+ videoContainers = [];
394
+ const urls = model.get("video_urls");
395
+ const selectedVideos = model.get("selected_videos") || [];
396
+ const layoutMode = model.get("layout_mode") || "row";
397
+
398
+ // Filter to only selected videos that have URLs
399
+ const videosToShow = selectedVideos.filter((name) => urls[name]);
400
+
401
+ if (videosToShow.length === 0) {
402
+ const emptyMsg = document.createElement("div");
403
+ emptyMsg.classList.add("video-widget__empty-grid-msg");
404
+ emptyMsg.textContent = "Select videos above to display them here.";
405
+ gridContainer.appendChild(emptyMsg);
406
+ return;
407
+ }
408
+
409
+ // Calculate grid dimensions based on layout mode
410
+ const { rows: numRows, cols: numCols } = calculateGridDimensions(
411
+ layoutMode,
412
+ videosToShow.length
413
+ );
414
+
415
+ gridContainer.style.gridTemplateColumns = "repeat(" + numCols + ", auto)";
416
+ gridContainer.style.gridTemplateRows = "repeat(" + numRows + ", auto)";
417
+
418
+ // Place videos in grid cells based on layout mode
419
+ videosToShow.forEach((name, index) => {
420
+ const url = urls[name];
421
+ const rowIdx = Math.floor(index / numCols);
422
+ const colIdx = index % numCols;
423
+
424
+ const videoCell = document.createElement("div");
425
+ videoCell.classList.add("video-widget__video-cell");
426
+ videoCell.style.gridRow = rowIdx + 1;
427
+ videoCell.style.gridColumn = colIdx + 1;
428
+
429
+ const videoContainer = document.createElement("div");
430
+ videoContainer.classList.add("video-widget__video-container");
431
+ videoContainers.push(videoContainer);
432
+
433
+ const video = document.createElement("video");
434
+ video.classList.add("video-widget__video");
435
+ video.src = url;
436
+ video.muted = true; // Mute to allow autoplay
437
+ video.preload = "auto"; // Preload video data
438
+ videos.push(video);
439
+
440
+ // Loading spinner
441
+ const loadingDiv = document.createElement("div");
442
+ loadingDiv.classList.add("video-widget__loading");
443
+ const spinner = document.createElement("div");
444
+ spinner.classList.add("video-widget__spinner");
445
+ loadingDiv.appendChild(spinner);
446
+
447
+ // Video loading events
448
+ video.addEventListener("loadstart", () => {
449
+ videoContainer.classList.add("video-widget__video-container--loading");
450
+ });
451
+ video.addEventListener("canplay", () => {
452
+ videoContainer.classList.remove(
453
+ "video-widget__video-container--loading"
454
+ );
455
+ });
456
+ video.addEventListener("error", () => {
457
+ console.error("Video error for " + name + ":", video.error);
458
+ videoContainer.classList.remove(
459
+ "video-widget__video-container--loading"
460
+ );
461
+ });
462
+
463
+ videoContainer.appendChild(video);
464
+ videoContainer.appendChild(loadingDiv);
465
+
466
+ const label = document.createElement("p");
467
+ label.textContent = name.replace("Video", "").replace("Camera", "");
468
+ label.classList.add("video-widget__video-label");
469
+
470
+ videoCell.appendChild(videoContainer);
471
+ videoCell.appendChild(label);
472
+ gridContainer.appendChild(videoCell);
473
+ });
474
+
475
+ // Update seek bar max when metadata loads
476
+ if (videos.length > 0) {
477
+ videos[0].addEventListener("loadedmetadata", () => {
478
+ seekBar.max = videos[0].duration;
479
+ const offset = getSessionTimeOffset();
480
+ const endTime = getSessionEndTime();
481
+ const displayEnd = endTime !== null ? endTime : offset + videos[0].duration;
482
+ timeLabel.textContent = formatTime(offset) + " / " + formatTime(displayEnd);
483
+ });
484
+ videos[0].addEventListener("timeupdate", () => {
485
+ if (!seekBar.matches(":active")) {
486
+ seekBar.value = videos[0].currentTime;
487
+ }
488
+ const offset = getSessionTimeOffset();
489
+ const endTime = getSessionEndTime();
490
+ const displayEnd = endTime !== null ? endTime : offset + videos[0].duration;
491
+ const currentSessionTime = offset + videos[0].currentTime;
492
+ timeLabel.textContent =
493
+ formatTime(currentSessionTime) + " / " + formatTime(displayEnd);
494
+ });
495
+ // Handle video end
496
+ videos[0].addEventListener("ended", () => {
497
+ isPlaying = false;
498
+ updatePlayPauseButton(false);
499
+ if (syncAnimationId) {
500
+ cancelAnimationFrame(syncAnimationId);
501
+ syncAnimationId = null;
502
+ }
503
+ });
504
+ }
505
+ }
506
+
507
+ playPauseBtn.addEventListener("click", async () => {
508
+ if (isPlaying) {
509
+ videos.forEach((v) => v.pause());
510
+ if (syncAnimationId) {
511
+ cancelAnimationFrame(syncAnimationId);
512
+ syncAnimationId = null;
513
+ }
514
+ isPlaying = false;
515
+ updatePlayPauseButton(false);
516
+ } else {
517
+ // Play all videos and wait for them to start
518
+ const playPromises = videos.map((v) =>
519
+ v.play().catch((err) => {
520
+ console.warn("Video play failed:", err);
521
+ })
522
+ );
523
+ await Promise.all(playPromises);
524
+ isPlaying = true;
525
+ updatePlayPauseButton(true);
526
+ syncVideos(); // Start synchronization loop
527
+ }
528
+ });
529
+
530
+ seekBar.addEventListener("input", () => {
531
+ const time = parseFloat(seekBar.value);
532
+ videos.forEach((v) => (v.currentTime = time));
533
+ });
534
+
535
+ settingsBtn.addEventListener("click", () => {
536
+ const isOpen = model.get("settings_open");
537
+ model.set("settings_open", !isOpen);
538
+ model.save_changes();
539
+ });
540
+
541
+ model.on("change:video_urls", updateVideos);
542
+ model.on("change:selected_videos", () => {
543
+ updateVideos();
544
+ updateSettingsPanel();
545
+ });
546
+ model.on("change:layout_mode", updateVideos);
547
+ model.on("change:settings_open", updateSettingsPanel);
548
+ model.on("change:available_videos", updateSettingsPanel);
549
+
550
+ updateVideos();
551
+ updateSettingsPanel();
552
+
553
+ wrapper.appendChild(settingsPanel);
554
+ wrapper.appendChild(gridContainer);
555
+ wrapper.appendChild(controls);
556
+ el.appendChild(wrapper);
557
+
558
+ // Cleanup function (called when widget is destroyed)
559
+ return () => {
560
+ if (syncAnimationId) {
561
+ cancelAnimationFrame(syncAnimationId);
562
+ }
563
+ };
564
+ }
565
+
566
+ export default { render };