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.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- 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)
|