seavision-python 0.1.1__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.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. seavision_python-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,645 @@
1
+ """
2
+ S3 bucket browser dialog for opening sessions or videos from S3.
3
+
4
+ The dialog operates in one of two modes, set at construction time:
5
+
6
+ S3BrowserMode.VIDEO
7
+ The user browses and selects a single video file.
8
+ The caller reads ``selected_video_uri`` after accept.
9
+
10
+ S3BrowserMode.SESSION
11
+ The user selects a detection CSV file and a video directory
12
+ (S3 prefix). The caller reads ``selected_csv_uri`` and
13
+ ``selected_video_prefix`` after accept.
14
+
15
+ Both modes share the same connection and navigation UI. The selection
16
+ panel at the bottom changes depending on the mode.
17
+
18
+ This module depends on boto3 (imported lazily inside the worker) and
19
+ PySide6.
20
+ """
21
+
22
+ import logging
23
+ from enum import Enum, auto
24
+ from pathlib import PurePosixPath
25
+
26
+ from PySide6.QtCore import Qt, Signal, QThread, QObject, Slot
27
+ from PySide6.QtWidgets import (
28
+ QApplication,
29
+ QComboBox,
30
+ QDialog,
31
+ QGroupBox,
32
+ QHBoxLayout,
33
+ QLabel,
34
+ QLineEdit,
35
+ QMessageBox,
36
+ QPushButton,
37
+ QStyle,
38
+ QTreeWidget,
39
+ QTreeWidgetItem,
40
+ QVBoxLayout,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ _VIDEO_EXTENSIONS = frozenset({
47
+ ".ts", ".mts", ".m2ts",
48
+ ".mp4", ".m4v",
49
+ ".avi",
50
+ ".mkv",
51
+ ".mov",
52
+ })
53
+
54
+ class S3BrowserMode(Enum):
55
+ VIDEO = auto()
56
+ SESSION = auto()
57
+
58
+
59
+ # --- Background worker - runs S3 list operations off the main thread ---
60
+ class _S3ListWorker(QObject):
61
+ """
62
+ Background worker for listing S3 bucket contents.
63
+
64
+ Runs on a dedicated QThread so that network calls don't block the UI. Emits
65
+ listing_ready with the results, or error_occurred on failure.
66
+ """
67
+
68
+ request_listing = Signal(str, str, str) # bucket, prefix, profile
69
+ listing_ready = Signal(str, list) # prefix, items
70
+ error_occurred = Signal(str) # error message
71
+
72
+ def __init__(self, parent=None):
73
+ super().__init__(parent)
74
+ self._cancelled = False
75
+ self.request_listing.connect(self.list_prefix)
76
+
77
+ @Slot(str, str, str)
78
+ def list_prefix(
79
+ self, bucket: str, prefix: str, profile: str
80
+ ) -> None:
81
+ """
82
+ List objects and common prefixes under an S3 path.
83
+
84
+ Each item in the emitted list is a dict with keys:
85
+ key — full S3 key (e.g. "videos/clip_001.ts")
86
+ is_prefix — True for directories, False for files
87
+ display — short display name (e.g. "clip_001.ts")
88
+ size — file size in bytes (files only)
89
+ """
90
+ if self._cancelled:
91
+ return
92
+
93
+ try:
94
+ import boto3
95
+
96
+ session = boto3.Session(
97
+ profile_name=profile if profile else None
98
+ )
99
+ s3 = session.client("s3")
100
+
101
+ paginator = s3.get_paginator("list_objects_v2")
102
+ items: list[dict] = []
103
+
104
+ for page in paginator.paginate(
105
+ Bucket=bucket, Prefix=prefix, Delimiter="/"
106
+ ):
107
+ # Directories (common prefixes)
108
+ for cp in page.get("CommonPrefixes", []):
109
+ items.append({
110
+ "key": cp["Prefix"],
111
+ "is_prefix": True,
112
+ "display": PurePosixPath(
113
+ cp["Prefix"].rstrip("/")
114
+ ).name + "/",
115
+ })
116
+
117
+ # Files (objects)
118
+ for obj in page.get("Contents", []):
119
+ key = obj["Key"]
120
+ if key == prefix:
121
+ continue
122
+ items.append({
123
+ "key": key,
124
+ "is_prefix": False,
125
+ "display": PurePosixPath(key).name,
126
+ "size": obj.get("Size", 0),
127
+ })
128
+
129
+ self.listing_ready.emit(prefix, items)
130
+
131
+ except Exception as e:
132
+ self.error_occurred.emit(str(e))
133
+
134
+ def cancel(self) -> None:
135
+ """Cancel the current listing operation."""
136
+ self._cancelled = True
137
+
138
+
139
+ # --- Dialog ---
140
+ class S3BrowserDialog(QDialog):
141
+ """
142
+ Dialog for browsing an S3 bucket.
143
+
144
+ The dialog operates in one of two modes (set via the ``mode``
145
+ constructor parameter):
146
+
147
+ **VIDEO mode** — the user selects a single video file.
148
+ After accept, read ``selected_video_uri``.
149
+
150
+ **SESSION mode** — the user selects a CSV and a video directory.
151
+ After accept, read ``selected_csv_uri`` and ``selected_video_prefix``.
152
+
153
+ In both modes, ``selected_profile`` returns the chosen AWS profile.
154
+
155
+ Usage::
156
+
157
+ # Video mode
158
+ dlg = S3BrowserDialog(profiles, S3BrowserMode.VIDEO, parent=self)
159
+ if dlg.exec() == QDialog.DialogCode.Accepted:
160
+ uri = dlg.selected_video_uri
161
+
162
+ # Session mode
163
+ dlg = S3BrowserDialog(profiles, S3BrowserMode.SESSION, parent=self)
164
+ if dlg.exec() == QDialog.DialogCode.Accepted:
165
+ csv = dlg.selected_csv_uri
166
+ pfx = dlg.selected_video_prefix
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ profiles: list[str],
172
+ mode: S3BrowserMode,
173
+ parent=None
174
+ ):
175
+ super().__init__(parent)
176
+ self._mode = mode
177
+ self.setMinimumSize(600, 500)
178
+ self.resize(750, 550)
179
+
180
+ if mode == S3BrowserMode.VIDEO:
181
+ self.setWindowTitle("Open Video from S3")
182
+ else:
183
+ self.setWindowTitle("Open Session from S3")
184
+
185
+ # --- Internal state ---
186
+ self._selected_csv_uri: str | None = None
187
+ self._selected_video_prefix: str | None = None
188
+ self._selected_video_uri: str | None = None
189
+ self._selected_profile: str | None = None
190
+ self._current_bucket: str = ""
191
+ self._current_prefix: str = ""
192
+
193
+
194
+ # --- LAYOUT ---
195
+ layout = QVBoxLayout(self)
196
+
197
+ # --- Connection settings ---
198
+ conn_group = QGroupBox("Connection")
199
+ conn_layout = QVBoxLayout(conn_group)
200
+
201
+ profile_row = QHBoxLayout()
202
+ profile_row.addWidget(QLabel("AWS Profile:"))
203
+ self._profile_combo = QComboBox()
204
+ self._profile_combo.addItems(profiles)
205
+ if profiles:
206
+ self._profile_combo.setCurrentIndex(0)
207
+ profile_row.addWidget(self._profile_combo, stretch=1)
208
+ conn_layout.addLayout(profile_row)
209
+
210
+ bucket_row = QHBoxLayout()
211
+ bucket_row.addWidget(QLabel("Bucket:"))
212
+ self._bucket_input = QLineEdit()
213
+ self._bucket_input.setPlaceholderText("my-video-bucket")
214
+ self._bucket_input.returnPressed.connect(self._on_connect)
215
+ bucket_row.addWidget(self._bucket_input, stretch=1)
216
+ self._connect_btn = QPushButton("Connect")
217
+ self._connect_btn.clicked.connect(self._on_connect)
218
+ bucket_row.addWidget(self._connect_btn)
219
+ conn_layout.addLayout(bucket_row)
220
+
221
+ layout.addWidget(conn_group)
222
+
223
+ # --- File browaser ---
224
+ browser_group = QGroupBox("Browser")
225
+ browser_layout = QVBoxLayout(browser_group)
226
+
227
+ nav_row = QHBoxLayout()
228
+ self._up_btn = QPushButton("↑ Up")
229
+ self._up_btn.setToolTip("Navigate to parent directory")
230
+ self._up_btn.clicked.connect(self._on_navigate_up)
231
+ self._up_btn.setEnabled(False)
232
+ nav_row.addWidget(self._up_btn)
233
+
234
+ self._path_label = QLabel("Not connected")
235
+ self._path_label.setStyleSheet("color: #888;")
236
+ nav_row.addWidget(self._path_label, stretch=1)
237
+ browser_layout.addLayout(nav_row)
238
+
239
+ self._tree = QTreeWidget()
240
+ self._tree.setHeaderLabels(["Name", "Size"])
241
+ self._tree.setColumnWidth(0, 420)
242
+ self._tree.setRootIsDecorated(False)
243
+ self._tree.itemDoubleClicked.connect(
244
+ self._on_item_double_clicked
245
+ )
246
+ self._tree.currentItemChanged.connect(
247
+ self._on_selection_changed
248
+ )
249
+ browser_layout.addWidget(self._tree)
250
+
251
+ layout.addWidget(browser_group, stretch=1)
252
+
253
+ # --- Mode-specific selection panel ---
254
+ if mode == S3BrowserMode.SESSION:
255
+ self._build_session_panel(layout)
256
+ else:
257
+ self._build_video_panel(layout)
258
+
259
+ # --- Cancel button ---
260
+ cancel_row = QHBoxLayout()
261
+ cancel_row.addStretch()
262
+ cancel_btn = QPushButton("Cancel")
263
+ cancel_btn.clicked.connect(self.reject)
264
+ cancel_row.addWidget(cancel_btn)
265
+ layout.addLayout(cancel_row)
266
+
267
+ # --- BACKGROUND WORKER ---
268
+ self._worker = _S3ListWorker()
269
+ self._thread = QThread()
270
+ self._worker.moveToThread(self._thread)
271
+ self._worker.listing_ready.connect(self._on_listing_ready)
272
+ self._worker.error_occurred.connect(self._on_listing_error)
273
+ self._thread.start()
274
+
275
+ # --- MODE SPECIFIC PANEL BUILDERS ---
276
+ def _build_session_panel(self, parent_layout: QVBoxLayout) -> None:
277
+ """Build the CSV + video_dir selection panel for SESSION mode."""
278
+ group = QGroupBox("Session Selection")
279
+ group_layout = QVBoxLayout(group)
280
+
281
+ # CSV row
282
+ csv_row = QHBoxLayout()
283
+ csv_row.addWidget(QLabel("CSV:"))
284
+ self._csv_label = QLabel("(none — select a .csv file above)")
285
+ self._csv_label.setStyleSheet("color: #aaa;")
286
+ csv_row.addWidget(self._csv_label, stretch=1)
287
+ self._set_csv_btn = QPushButton("Set Selected as CSV")
288
+ self._set_csv_btn.setToolTip(
289
+ "Use the highlighted .csv file in the browser"
290
+ )
291
+ self._set_csv_btn.clicked.connect(self._on_set_csv)
292
+ self._set_csv_btn.setEnabled(False)
293
+ csv_row.addWidget(self._set_csv_btn)
294
+ group_layout.addLayout(csv_row)
295
+
296
+ # Video directory row
297
+ vdir_row = QHBoxLayout()
298
+ vdir_row.addWidget(QLabel("Video Dir:"))
299
+ self._video_dir_label = QLabel(
300
+ "(none — navigate to the video folder above)"
301
+ )
302
+ self._video_dir_label.setStyleSheet("color: #aaa;")
303
+ vdir_row.addWidget(self._video_dir_label, stretch=1)
304
+ self._set_vdir_btn = QPushButton("Set Current as Video Dir")
305
+ self._set_vdir_btn.setToolTip(
306
+ "Use the directory currently shown in the browser"
307
+ )
308
+ self._set_vdir_btn.clicked.connect(self._on_set_video_dir)
309
+ self._set_vdir_btn.setEnabled(False)
310
+ vdir_row.addWidget(self._set_vdir_btn)
311
+ group_layout.addLayout(vdir_row)
312
+
313
+ # Accept button
314
+ btn_row = QHBoxLayout()
315
+ btn_row.addStretch()
316
+ self._accept_btn = QPushButton("Open Session")
317
+ self._accept_btn.setEnabled(False)
318
+ self._accept_btn.setStyleSheet(
319
+ "QPushButton { background-color: #2d7a4d; color: white; "
320
+ "padding: 8px 20px; font-weight: bold; border-radius: 4px; }"
321
+ "QPushButton:disabled { background-color: #3a3a3a; "
322
+ "color: #777; }"
323
+ "QPushButton:hover { background-color: #359a5d; }"
324
+ )
325
+ self._accept_btn.clicked.connect(self._on_accept)
326
+ btn_row.addWidget(self._accept_btn)
327
+ group_layout.addLayout(btn_row)
328
+
329
+ parent_layout.addWidget(group)
330
+
331
+ def _build_video_panel(self, parent_layout: QVBoxLayout) -> None:
332
+ """Build the single-video selection panel for VIDEO mode."""
333
+ group = QGroupBox("Video Selection")
334
+ video_row = QHBoxLayout(group)
335
+
336
+ self._video_file_label = QLabel(
337
+ "Select a video file in the browser above"
338
+ )
339
+ self._video_file_label.setStyleSheet("color: #aaa;")
340
+ video_row.addWidget(self._video_file_label, stretch=1)
341
+
342
+ self._accept_btn = QPushButton("Open Video")
343
+ self._accept_btn.setEnabled(False)
344
+ self._accept_btn.setStyleSheet(
345
+ "QPushButton { background-color: #2d6da8; color: white; "
346
+ "padding: 8px 20px; font-weight: bold; border-radius: 4px; }"
347
+ "QPushButton:disabled { background-color: #3a3a3a; "
348
+ "color: #777; }"
349
+ "QPushButton:hover { background-color: #3d8dc8; }"
350
+ )
351
+ self._accept_btn.clicked.connect(self._on_accept)
352
+ video_row.addWidget(self._accept_btn)
353
+
354
+ parent_layout.addWidget(group)
355
+
356
+
357
+ # --- CONNECTION ---
358
+ def _on_connect(self) -> None:
359
+ """Connect to the specified bucket and list its root."""
360
+ bucket = self._bucket_input.text().strip()
361
+ if not bucket:
362
+ QMessageBox.warning(
363
+ self, "No Bucket",
364
+ "Please enter a bucket name."
365
+ )
366
+ return
367
+
368
+ self._current_bucket = bucket
369
+ self._current_prefix = ""
370
+ self._connect_btn.setEnabled(False)
371
+ self._connect_btn.setText("Connecting…")
372
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
373
+ self._refresh_listing()
374
+
375
+
376
+ # --- NAVIGATION ---
377
+
378
+ def _refresh_listing(self) -> None:
379
+ """Request a listing of the current prefix from the worker."""
380
+ self._tree.clear()
381
+ loading = QTreeWidgetItem(["Loading…"])
382
+ loading.setFlags(Qt.ItemFlag.NoItemFlags)
383
+ self._tree.addTopLevelItem(loading)
384
+
385
+ self._worker.request_listing.emit(
386
+ self._current_bucket,
387
+ self._current_prefix,
388
+ self._profile_combo.currentText(),
389
+ )
390
+
391
+ def _on_listing_ready(self, prefix: str, items: list) -> None:
392
+ """Populate the tree with S3 listing results."""
393
+ QApplication.restoreOverrideCursor()
394
+
395
+ # Ignore stale responses from a previous navigation
396
+ if prefix != self._current_prefix:
397
+ return
398
+
399
+ self._connect_btn.setEnabled(True)
400
+ self._connect_btn.setText("Connect")
401
+
402
+ self._tree.clear()
403
+ self._path_label.setText(
404
+ f"s3://{self._current_bucket}/{self._current_prefix}"
405
+ )
406
+ self._path_label.setStyleSheet("")
407
+ self._up_btn.setEnabled(bool(self._current_prefix))
408
+
409
+ # Enable the session mode buttons now that we're connected
410
+ if self._mode == S3BrowserMode.SESSION:
411
+ self._set_vdir_btn.setEnabled(True)
412
+ self._set_csv_btn.setEnabled(True)
413
+
414
+ if not items:
415
+ empty = QTreeWidgetItem(["(empty directory)"])
416
+ empty.setFlags(Qt.ItemFlag.NoItemFlags)
417
+ self._tree.addTopLevelItem(empty)
418
+ return
419
+
420
+ for item in items:
421
+ size_text = (
422
+ self._format_size(item.get("size", 0))
423
+ if not item["is_prefix"] else ""
424
+ )
425
+
426
+ node = QTreeWidgetItem([
427
+ item["display"],
428
+ size_text
429
+ ])
430
+ node.setData(0, Qt.ItemDataRole.UserRole, item)
431
+
432
+ if item["is_prefix"]:
433
+ node.setIcon(0, self.style().standardIcon(
434
+ QStyle.StandardPixmap.SP_DirIcon
435
+ ))
436
+
437
+ self._tree.addTopLevelItem(node)
438
+
439
+ def _on_listing_error(self, error: str) -> None:
440
+ """Handle an error from the S3 listing worker."""
441
+ self._connect_btn.setEnabled(True)
442
+ self._connect_btn.setText("Connect")
443
+ self._tree.clear()
444
+
445
+ if self._mode == S3BrowserMode.SESSION:
446
+ self._set_vdir_btn.setEnabled(False)
447
+ self._set_csv_btn.setEnabled(False)
448
+
449
+ QMessageBox.warning(
450
+ self,
451
+ "S3 Error",
452
+ f"Failed to list bucket contents:\n\n{error}\n\n"
453
+ f"Check that:\n"
454
+ f" • The bucket name is correct\n"
455
+ f" • Your AWS profile is valid\n"
456
+ f" • You have run 'aws sso login' if using SSO",
457
+ )
458
+
459
+ def _on_item_double_clicked(self, item, column) -> None:
460
+ """Navigate into a directory on double-click."""
461
+ data = item.data(0, Qt.ItemDataRole.UserRole)
462
+ if data and data["is_prefix"]:
463
+ self._current_prefix = data["key"]
464
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
465
+ self._refresh_listing()
466
+
467
+ def _on_navigate_up(self) -> None:
468
+ """Navigate to the parent prefix."""
469
+ if not self._current_prefix:
470
+ return
471
+ parts = self._current_prefix.rstrip("/").rsplit("/", 1)
472
+ self._current_prefix = (parts[0] + "/") if len(parts) > 1 else ""
473
+ self._refresh_listing()
474
+
475
+ def _on_selection_changed(self, current, previous) -> None:
476
+ """
477
+ React to the user selecting a different item in the tree.
478
+
479
+ In VIDEO mode: enable/disable the accept button based on whether the
480
+ selected item is a recognised video file.
481
+
482
+ In SESSION mode: no action needed - selection is committed explicitly
483
+ via the 'Set selected as CSV' button
484
+ """
485
+ if self._mode == S3BrowserMode.VIDEO:
486
+ self._update_video_selection(current)
487
+
488
+ def _update_video_selection(self, current) -> None:
489
+ """Update the video panel based on the current tree selection."""
490
+ if current is None:
491
+ self._accept_btn.setEnabled(False)
492
+ self._video_file_label.setText(
493
+ "Select a video file in the browser above"
494
+ )
495
+ self._video_file_label.setStyleSheet("color: #aaa;")
496
+ self._selected_video_uri = None
497
+ return
498
+
499
+ data = current.data(0, Qt.ItemDataRole.UserRole)
500
+ if data is None or data["is_prefix"]:
501
+ self._accept_btn.setEnabled(False)
502
+ self._video_file_label.setText(
503
+ "Select a video file in the browser above"
504
+ )
505
+ self._video_file_label.setStyleSheet("color: #aaa;")
506
+ self._selected_video_uri = None
507
+ return
508
+
509
+ suffix = PurePosixPath(data["display"].lower()).suffix
510
+ if suffix in _VIDEO_EXTENSIONS:
511
+ uri = f"s3://{self._current_bucket}/{data['key']}"
512
+ self._accept_btn.setEnabled(True)
513
+ self._video_file_label.setText(data["display"])
514
+ self._video_file_label.setStyleSheet("")
515
+ self._selected_video_uri = uri
516
+ else:
517
+ self._accept_btn.setEnabled(False)
518
+ self._video_file_label.setText(
519
+ f"'{data['display']}' is not a recognised video format."
520
+ )
521
+ self._video_file_label.setStyleSheet("color: #aaa;")
522
+ self._selected_video_uri = None
523
+
524
+
525
+ # --- SESSION-MODEL SELECTION ---
526
+ def _on_set_csv(self) -> None:
527
+ """Set the currently selected tree item as the CSV file."""
528
+ current = self._tree.currentItem()
529
+ if current is None:
530
+ QMessageBox.information(
531
+ self, "No selection",
532
+ "Select a .csv file in the browser first."
533
+ )
534
+ return
535
+
536
+ data = current.data(0, Qt.ItemDataRole.UserRole)
537
+ if data is None or data["is_prefix"]:
538
+ QMessageBox.information(
539
+ self, "Select a File",
540
+ "Please select a .csv file, not a directory."
541
+ )
542
+ return
543
+
544
+ if not data["display"].lower().endswith(".csv"):
545
+ QMessageBox.information(
546
+ self, "Not a CSV",
547
+ f"'{data['display']}' does not appear to be a CSV "
548
+ f"file.\n\nPlease select a file ending in .csv.",
549
+ )
550
+ return
551
+
552
+ uri = f"s3://{self._current_bucket}/{data['key']}"
553
+ self._selected_csv_uri = uri
554
+ self._csv_label.setText(uri)
555
+ self._csv_label.setStyleSheet(
556
+ "color: #2d7a4d; font-weight: bold;"
557
+ )
558
+ self._update_session_accept()
559
+
560
+ def _on_set_video_dir(self) -> None:
561
+ """Set the current browser prefix as the video directory."""
562
+ uri = f"s3://{self._current_bucket}/{self._current_prefix}"
563
+ self._selected_video_prefix = uri
564
+ self._video_dir_label.setText(uri)
565
+ self._video_dir_label.setStyleSheet(
566
+ "color: #2d7a4d; font-weight: bold;"
567
+ )
568
+ self._update_session_accept()
569
+
570
+ def _update_session_accept(self) -> None:
571
+ """Enable the session accept button when both field are set."""
572
+ self._accept_btn.setEnabled(
573
+ self._selected_csv_uri is not None
574
+ and self._selected_video_prefix is not None
575
+ )
576
+
577
+
578
+ # --- ACCEPT ---
579
+ def _on_accept(self) -> None:
580
+ """
581
+ Accept the dialog.
582
+
583
+ In VIDEO mode, validates that a video is selected.
584
+ In SESSION mode, alidates that both CSV and video dir are set.
585
+ (The accept button is already disabled when invalid, so this is a
586
+ safety net.)
587
+ """
588
+ if self._mode == S3BrowserMode.VIDEO:
589
+ if self._selected_video_uri is None:
590
+ return
591
+ else:
592
+ if (self._selected_csv_uri is None
593
+ or self._selected_video_prefix is None):
594
+ return
595
+
596
+ self._selected_profile = self._profile_combo.currentText()
597
+ self.accept()
598
+
599
+
600
+ # --- PUBLIC PROPERTIES ---
601
+ @property
602
+ def selected_csv_uri(self) -> str | None:
603
+ """S3 URI of the selected CSV (SESSION mode only)."""
604
+ return self._selected_csv_uri
605
+
606
+ @property
607
+ def selected_video_prefix(self) -> str | None:
608
+ """S3 URI prefix for the video directory (SESSION mode only)."""
609
+ return self._selected_video_prefix
610
+
611
+ @property
612
+ def selected_video_uri(self) -> str | None:
613
+ """S3 URI of the selected video file (VIDEO mode only)."""
614
+ return self._selected_video_uri
615
+
616
+ @property
617
+ def selected_profile(self) -> str | None:
618
+ """The AWS profile name that was selected."""
619
+ return self._selected_profile
620
+
621
+ # --- HELPERS ---
622
+ @staticmethod
623
+ def _format_size(size_bytes: int) -> str:
624
+ """Format a byte count as a human-readable string."""
625
+ if size_bytes == 0:
626
+ return ""
627
+ for unit in ("B", "KB", "MB", "GB"):
628
+ if size_bytes < 1024:
629
+ return f"{size_bytes:.1f} {unit}"
630
+ size_bytes /= 1024
631
+ return f"{size_bytes:.1f} TB"
632
+
633
+ def done(self, result) -> None:
634
+ """Shut down the background thread when the dialog closes."""
635
+ self._worker.cancel()
636
+ self._thread.quit()
637
+ # self._thread.wait(2000)
638
+ super().done(result)
639
+
640
+ def closeEvent(self, event) -> None:
641
+ """Also handle the X button."""
642
+ if self._thread.isRunning():
643
+ self._thread.quit()
644
+ self._thread.wait()
645
+ super().closeEvent(event)