powertech-cli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/PKG-INFO +24 -11
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/README.md +23 -10
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/pyproject.toml +1 -1
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/__init__.py +1 -1
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/api.py +52 -0
- powertech_cli-0.2.0/src/powertech_cli/intrinsics.py +81 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/main.py +32 -15
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/utils.py +3 -4
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/PKG-INFO +24 -11
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/SOURCES.txt +2 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/tests/test_cli_flow.py +58 -2
- powertech_cli-0.2.0/tests/test_intrinsics.py +58 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/setup.cfg +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/__main__.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/checkpoint.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/config.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/errors.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/__init__.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/local.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/oss.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/dependency_links.txt +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/entry_points.txt +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/requires.txt +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/top_level.txt +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/tests/test_downloads.py +0 -0
- {powertech_cli-0.1.0 → powertech_cli-0.2.0}/tests/test_video_metadata.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: powertech-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: PowerTech CLI for batch video upload and result download.
|
|
5
5
|
Author: PowerTech
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -35,13 +35,13 @@ Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
|
|
|
35
35
|
development SaaS web app:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
POWERTECH_CLI_HOME=~/.powertech/
|
|
38
|
+
POWERTECH_CLI_HOME=~/.powertech/development \
|
|
39
39
|
powertech login --token <development_token> --api-url http://127.0.0.1:8000
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
### Staging
|
|
43
43
|
|
|
44
|
-
Use the same
|
|
44
|
+
Use the same package and save staging credentials separately:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
@@ -66,17 +66,30 @@ is saved with the token, so later upload and download commands do not repeat it.
|
|
|
66
66
|
|
|
67
67
|
## Upload
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
Every upload command requires one camera intrinsics JSON. It is validated locally,
|
|
70
|
+
then applied to every video selected by that command. Directory inputs recursively
|
|
71
|
+
find MP4 and MOV files and preserve relative paths. Interrupted uploads resume from
|
|
72
|
+
local checkpoints automatically.
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
POWERTECH_CLI_HOME=~/.powertech/staging
|
|
74
|
-
|
|
75
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
76
|
+
powertech upload ./video.mp4 --intrinsics ./camera-a.json
|
|
77
|
+
|
|
78
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
79
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
80
|
+
|
|
75
81
|
POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
|
|
76
82
|
```
|
|
77
83
|
|
|
78
84
|
Supported input formats: MP4 and MOV.
|
|
79
85
|
|
|
86
|
+
Run separate commands when videos use different cameras or intrinsics:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
90
|
+
powertech upload ./camera-b-videos --intrinsics ./camera-b.json
|
|
91
|
+
```
|
|
92
|
+
|
|
80
93
|
## Download
|
|
81
94
|
|
|
82
95
|
```bash
|
|
@@ -93,7 +106,7 @@ The isolated mock adapter does not use RDS or OSS:
|
|
|
93
106
|
|
|
94
107
|
```bash
|
|
95
108
|
powertech login --token dev_token --api-url local
|
|
96
|
-
powertech upload ./videos --
|
|
109
|
+
powertech upload ./videos --intrinsics ./camera-a.json
|
|
97
110
|
```
|
|
98
111
|
|
|
99
112
|
CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
|
|
@@ -102,6 +115,6 @@ directory during tests.
|
|
|
102
115
|
## Continuous integration
|
|
103
116
|
|
|
104
117
|
Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
|
|
105
|
-
test.
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
test. Staging does not deploy or require a separate CLI package. A `vX.Y.Z` tag
|
|
119
|
+
whose commit belongs to `main` and whose value matches the package version publishes
|
|
120
|
+
to PyPI through its trusted publisher; manual dispatch remains a recovery path.
|
|
@@ -22,13 +22,13 @@ Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
|
|
|
22
22
|
development SaaS web app:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
POWERTECH_CLI_HOME=~/.powertech/
|
|
25
|
+
POWERTECH_CLI_HOME=~/.powertech/development \
|
|
26
26
|
powertech login --token <development_token> --api-url http://127.0.0.1:8000
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
### Staging
|
|
30
30
|
|
|
31
|
-
Use the same
|
|
31
|
+
Use the same package and save staging credentials separately:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
@@ -53,17 +53,30 @@ is saved with the token, so later upload and download commands do not repeat it.
|
|
|
53
53
|
|
|
54
54
|
## Upload
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
Every upload command requires one camera intrinsics JSON. It is validated locally,
|
|
57
|
+
then applied to every video selected by that command. Directory inputs recursively
|
|
58
|
+
find MP4 and MOV files and preserve relative paths. Interrupted uploads resume from
|
|
59
|
+
local checkpoints automatically.
|
|
58
60
|
|
|
59
61
|
```bash
|
|
60
|
-
POWERTECH_CLI_HOME=~/.powertech/staging
|
|
61
|
-
|
|
62
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
63
|
+
powertech upload ./video.mp4 --intrinsics ./camera-a.json
|
|
64
|
+
|
|
65
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
66
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
67
|
+
|
|
62
68
|
POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
|
|
63
69
|
```
|
|
64
70
|
|
|
65
71
|
Supported input formats: MP4 and MOV.
|
|
66
72
|
|
|
73
|
+
Run separate commands when videos use different cameras or intrinsics:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
77
|
+
powertech upload ./camera-b-videos --intrinsics ./camera-b.json
|
|
78
|
+
```
|
|
79
|
+
|
|
67
80
|
## Download
|
|
68
81
|
|
|
69
82
|
```bash
|
|
@@ -80,7 +93,7 @@ The isolated mock adapter does not use RDS or OSS:
|
|
|
80
93
|
|
|
81
94
|
```bash
|
|
82
95
|
powertech login --token dev_token --api-url local
|
|
83
|
-
powertech upload ./videos --
|
|
96
|
+
powertech upload ./videos --intrinsics ./camera-a.json
|
|
84
97
|
```
|
|
85
98
|
|
|
86
99
|
CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
|
|
@@ -89,6 +102,6 @@ directory during tests.
|
|
|
89
102
|
## Continuous integration
|
|
90
103
|
|
|
91
104
|
Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
|
|
92
|
-
test.
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
test. Staging does not deploy or require a separate CLI package. A `vX.Y.Z` tag
|
|
106
|
+
whose commit belongs to `main` and whose value matches the package version publishes
|
|
107
|
+
to PyPI through its trusted publisher; manual dispatch remains a recovery path.
|
|
@@ -74,6 +74,19 @@ class HttpBackendClient:
|
|
|
74
74
|
def finish_upload_batch(self, upload_batch_id: str) -> dict:
|
|
75
75
|
return self._request("POST", f"/upload-batches/{upload_batch_id}/finish")
|
|
76
76
|
|
|
77
|
+
def attach_upload_batch_materials(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
upload_batch_id: str,
|
|
81
|
+
relative_paths: list[str],
|
|
82
|
+
intrinsics: dict,
|
|
83
|
+
) -> list[dict]:
|
|
84
|
+
return self._request(
|
|
85
|
+
"PUT",
|
|
86
|
+
f"/upload-batches/{upload_batch_id}/materials",
|
|
87
|
+
{"relative_paths": relative_paths, "intrinsics": intrinsics},
|
|
88
|
+
)
|
|
89
|
+
|
|
77
90
|
def get_results(self, upload_batch_id: str) -> dict:
|
|
78
91
|
return self._request("GET", f"/upload-batches/{upload_batch_id}/results")
|
|
79
92
|
|
|
@@ -200,6 +213,9 @@ class LocalBackendClient:
|
|
|
200
213
|
"storage_provider": "mock",
|
|
201
214
|
"status": "uploaded",
|
|
202
215
|
"upload_source": "cli",
|
|
216
|
+
"camera_intrinsics": None,
|
|
217
|
+
"camera_intrinsics_revision": 0,
|
|
218
|
+
"material_submitted_at": None,
|
|
203
219
|
"created_at": utc_now(),
|
|
204
220
|
}
|
|
205
221
|
batch["status"] = "uploading"
|
|
@@ -207,6 +223,42 @@ class LocalBackendClient:
|
|
|
207
223
|
self._save_state(state)
|
|
208
224
|
return {"upload_batch_id": upload_batch_id, "uploaded_file": uploaded_file}
|
|
209
225
|
|
|
226
|
+
def attach_upload_batch_materials(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
upload_batch_id: str,
|
|
230
|
+
relative_paths: list[str],
|
|
231
|
+
intrinsics: dict,
|
|
232
|
+
) -> list[dict]:
|
|
233
|
+
state = self._load_state()
|
|
234
|
+
_require_batch(state, upload_batch_id)
|
|
235
|
+
files = [
|
|
236
|
+
item for item in state["uploaded_files"].values() if item["upload_batch_id"] == upload_batch_id
|
|
237
|
+
]
|
|
238
|
+
actual_paths = {item["relative_path"] for item in files}
|
|
239
|
+
if actual_paths != set(relative_paths):
|
|
240
|
+
raise CliError("The upload batch files do not match this command. Resume with the same video paths.")
|
|
241
|
+
|
|
242
|
+
candidate = {
|
|
243
|
+
"schema_version": "camera-intrinsics.v1",
|
|
244
|
+
"source": "user_provided",
|
|
245
|
+
**intrinsics,
|
|
246
|
+
}
|
|
247
|
+
submitted_at = utc_now()
|
|
248
|
+
for item in files:
|
|
249
|
+
existing = item.get("camera_intrinsics")
|
|
250
|
+
if existing is not None and existing != candidate:
|
|
251
|
+
raise CliError(
|
|
252
|
+
"This upload batch already has different camera intrinsics. "
|
|
253
|
+
"Use the Web app to replace them explicitly."
|
|
254
|
+
)
|
|
255
|
+
if existing is None:
|
|
256
|
+
item["camera_intrinsics"] = candidate
|
|
257
|
+
item["camera_intrinsics_revision"] = item.get("camera_intrinsics_revision", 0) + 1
|
|
258
|
+
item["material_submitted_at"] = item.get("material_submitted_at") or submitted_at
|
|
259
|
+
self._save_state(state)
|
|
260
|
+
return files
|
|
261
|
+
|
|
210
262
|
def finish_upload_batch(self, upload_batch_id: str) -> dict:
|
|
211
263
|
state = self._load_state()
|
|
212
264
|
batch = _require_batch(state, upload_batch_id)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import CliError
|
|
9
|
+
|
|
10
|
+
MAX_INTRINSICS_FILE_BYTES = 64 * 1024
|
|
11
|
+
REQUIRED_KEYS = {
|
|
12
|
+
"image_width",
|
|
13
|
+
"image_height",
|
|
14
|
+
"fx",
|
|
15
|
+
"fy",
|
|
16
|
+
"cx",
|
|
17
|
+
"cy",
|
|
18
|
+
"k1",
|
|
19
|
+
"k2",
|
|
20
|
+
"k3",
|
|
21
|
+
"k4",
|
|
22
|
+
"camera_model",
|
|
23
|
+
"p1",
|
|
24
|
+
"p2",
|
|
25
|
+
}
|
|
26
|
+
FLOAT_KEYS = {"fx", "fy", "cx", "cy", "k1", "k2", "k3", "k4", "p1", "p2"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_camera_intrinsics(raw_path: str | Path) -> dict[str, Any]:
|
|
30
|
+
path = Path(raw_path).expanduser()
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
raise CliError(f"Camera intrinsics file does not exist: {path}")
|
|
33
|
+
if path.suffix.lower() != ".json":
|
|
34
|
+
raise CliError("Camera intrinsics must be provided as a JSON file.")
|
|
35
|
+
if path.stat().st_size > MAX_INTRINSICS_FILE_BYTES:
|
|
36
|
+
raise CliError("Camera intrinsics JSON must not exceed 64 KiB.")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
parameters = json.loads(
|
|
40
|
+
path.read_text(encoding="utf-8"),
|
|
41
|
+
parse_constant=lambda value: _reject_non_finite(value),
|
|
42
|
+
)
|
|
43
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
44
|
+
raise CliError(f"Could not read camera intrinsics JSON: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
if not isinstance(parameters, dict):
|
|
47
|
+
raise CliError("Camera intrinsics JSON must contain one object.")
|
|
48
|
+
missing = sorted(REQUIRED_KEYS - parameters.keys())
|
|
49
|
+
extra = sorted(parameters.keys() - REQUIRED_KEYS)
|
|
50
|
+
if missing or extra:
|
|
51
|
+
details: list[str] = []
|
|
52
|
+
if missing:
|
|
53
|
+
details.append(f"missing: {', '.join(missing)}")
|
|
54
|
+
if extra:
|
|
55
|
+
details.append(f"unexpected: {', '.join(extra)}")
|
|
56
|
+
raise CliError(f"Invalid camera intrinsics fields ({'; '.join(details)}).")
|
|
57
|
+
|
|
58
|
+
for key in ("image_width", "image_height"):
|
|
59
|
+
value = parameters[key]
|
|
60
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
61
|
+
raise CliError(f"Camera intrinsics field {key} must be a positive integer.")
|
|
62
|
+
for key in FLOAT_KEYS:
|
|
63
|
+
value = parameters[key]
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
65
|
+
raise CliError(f"Camera intrinsics field {key} must be a finite number.")
|
|
66
|
+
parameters[key] = float(value)
|
|
67
|
+
for key in ("fx", "fy"):
|
|
68
|
+
if parameters[key] <= 0:
|
|
69
|
+
raise CliError(f"Camera intrinsics field {key} must be greater than 0.")
|
|
70
|
+
if parameters["camera_model"] != "PinHole":
|
|
71
|
+
raise CliError("Camera intrinsics field camera_model must be PinHole.")
|
|
72
|
+
if not 0 <= parameters["cx"] <= parameters["image_width"]:
|
|
73
|
+
raise CliError("Camera intrinsics field cx must be within the image width.")
|
|
74
|
+
if not 0 <= parameters["cy"] <= parameters["image_height"]:
|
|
75
|
+
raise CliError("Camera intrinsics field cy must be within the image height.")
|
|
76
|
+
|
|
77
|
+
return {"file_name": path.name, "parameters": parameters}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _reject_non_finite(value: str) -> None:
|
|
81
|
+
raise ValueError(f"non-finite number: {value}")
|
|
@@ -10,6 +10,7 @@ from .api import build_backend_client, download_file
|
|
|
10
10
|
from .checkpoint import CheckpointStore, UploadCheckpoint, compute_upload_fingerprint
|
|
11
11
|
from .config import DEFAULT_API_URL, DEFAULT_ORG_ID, DEFAULT_STORAGE, CliConfig, load_config, save_config
|
|
12
12
|
from .errors import CliError
|
|
13
|
+
from .intrinsics import load_camera_intrinsics
|
|
13
14
|
from .storage import build_storage_client
|
|
14
15
|
from .utils import collect_video_files, format_bytes, read_video_duration_ms, utc_now
|
|
15
16
|
|
|
@@ -44,8 +45,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
44
45
|
|
|
45
46
|
upload = subparsers.add_parser("upload", help="Upload a video file or directory")
|
|
46
47
|
upload.add_argument("paths", nargs="+", help="Video file or directory paths")
|
|
47
|
-
upload.add_argument(
|
|
48
|
-
|
|
48
|
+
upload.add_argument(
|
|
49
|
+
"--intrinsics",
|
|
50
|
+
required=True,
|
|
51
|
+
help="Camera intrinsics JSON applied to every video in this command",
|
|
52
|
+
)
|
|
49
53
|
upload.add_argument("--dry-run", action="store_true", help="Print matched files without uploading")
|
|
50
54
|
upload.add_argument("--chunk-size-mb", type=float, default=None, help="Override upload chunk size in MiB")
|
|
51
55
|
upload.set_defaults(handler=handle_upload)
|
|
@@ -85,29 +89,38 @@ def handle_login(args: argparse.Namespace) -> int:
|
|
|
85
89
|
|
|
86
90
|
|
|
87
91
|
def handle_upload(args: argparse.Namespace) -> int:
|
|
88
|
-
|
|
89
|
-
if args.chunk_size_mb is not None:
|
|
90
|
-
config.chunk_size_bytes = _chunk_size_bytes(args.chunk_size_mb)
|
|
91
|
-
|
|
92
|
-
backend = build_backend_client(config)
|
|
93
|
-
checkpoint_store = CheckpointStore()
|
|
94
|
-
|
|
92
|
+
intrinsics = load_camera_intrinsics(args.intrinsics)
|
|
95
93
|
upload_items: list[tuple[Path, str]] = []
|
|
96
94
|
for raw_path in args.paths:
|
|
97
|
-
upload_items.extend(collect_video_files(Path(raw_path)
|
|
95
|
+
upload_items.extend(collect_video_files(Path(raw_path)))
|
|
98
96
|
|
|
99
97
|
if not upload_items:
|
|
100
98
|
raise CliError("No supported MP4 or MOV files were found.")
|
|
99
|
+
relative_names = [relative_name for _, relative_name in upload_items]
|
|
100
|
+
duplicate_names = sorted({name for name in relative_names if relative_names.count(name) > 1})
|
|
101
|
+
if duplicate_names:
|
|
102
|
+
raise CliError(
|
|
103
|
+
"Selected paths produce duplicate upload names: "
|
|
104
|
+
f"{', '.join(duplicate_names)}. Upload those inputs separately."
|
|
105
|
+
)
|
|
101
106
|
|
|
102
107
|
if args.dry_run:
|
|
103
108
|
print(f"Matched {len(upload_items)} video file(s):")
|
|
109
|
+
print(f"Camera intrinsics: {args.intrinsics} (applies to every matched video)")
|
|
104
110
|
for source_path, relative_name in upload_items:
|
|
105
111
|
print(f"- {source_path} -> {relative_name} ({format_bytes(source_path.stat().st_size)})")
|
|
106
112
|
return 0
|
|
107
113
|
|
|
114
|
+
config = load_config()
|
|
115
|
+
if args.chunk_size_mb is not None:
|
|
116
|
+
config.chunk_size_bytes = _chunk_size_bytes(args.chunk_size_mb)
|
|
117
|
+
|
|
118
|
+
backend = build_backend_client(config)
|
|
119
|
+
checkpoint_store = CheckpointStore()
|
|
120
|
+
|
|
108
121
|
checkpoint_identity = _checkpoint_identity(config)
|
|
109
122
|
checkpoints = {
|
|
110
|
-
fingerprint: checkpoint_store.load(fingerprint)
|
|
123
|
+
fingerprint: checkpoint_store.load(fingerprint)
|
|
111
124
|
for fingerprint in [compute_upload_fingerprint(path, checkpoint_identity) for path, _ in upload_items]
|
|
112
125
|
}
|
|
113
126
|
resumable_batch_ids = {item.upload_batch_id for item in checkpoints.values() if item is not None}
|
|
@@ -131,8 +144,13 @@ def handle_upload(args: argparse.Namespace) -> int:
|
|
|
131
144
|
upload_batch_id=upload_batch_id,
|
|
132
145
|
source_path=source_path,
|
|
133
146
|
relative_name=relative_name,
|
|
134
|
-
resume=args.resume,
|
|
135
147
|
)
|
|
148
|
+
backend.attach_upload_batch_materials(
|
|
149
|
+
upload_batch_id=upload_batch_id,
|
|
150
|
+
relative_paths=relative_names,
|
|
151
|
+
intrinsics=intrinsics,
|
|
152
|
+
)
|
|
153
|
+
print(f"Attached {intrinsics['file_name']} to {len(upload_items)} video file(s)")
|
|
136
154
|
backend.finish_upload_batch(upload_batch_id)
|
|
137
155
|
for source_path, _ in upload_items:
|
|
138
156
|
checkpoint_store.delete(compute_upload_fingerprint(source_path, checkpoint_identity))
|
|
@@ -228,10 +246,9 @@ def _upload_one(
|
|
|
228
246
|
upload_batch_id: str,
|
|
229
247
|
source_path: Path,
|
|
230
248
|
relative_name: str,
|
|
231
|
-
resume: bool,
|
|
232
249
|
) -> None:
|
|
233
250
|
fingerprint = compute_upload_fingerprint(source_path, _checkpoint_identity(config))
|
|
234
|
-
checkpoint = checkpoint_store.load(fingerprint)
|
|
251
|
+
checkpoint = checkpoint_store.load(fingerprint)
|
|
235
252
|
if checkpoint:
|
|
236
253
|
if checkpoint.completed:
|
|
237
254
|
print(f"Already uploaded in this batch: {source_path}")
|
|
@@ -274,7 +291,7 @@ def _upload_one(
|
|
|
274
291
|
object_key=checkpoint.object_key,
|
|
275
292
|
checkpoint=checkpoint,
|
|
276
293
|
checkpoint_store=checkpoint_store,
|
|
277
|
-
resume=
|
|
294
|
+
resume=True,
|
|
278
295
|
)
|
|
279
296
|
completed = backend.complete_upload(
|
|
280
297
|
upload_batch_id=upload_batch_id,
|
|
@@ -87,7 +87,7 @@ def ensure_supported_video(path: Path) -> None:
|
|
|
87
87
|
raise CliError(f"Unsupported file type: {path.name}. Supported video types: {supported}")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def collect_video_files(input_path: Path
|
|
90
|
+
def collect_video_files(input_path: Path) -> list[tuple[Path, str]]:
|
|
91
91
|
path = input_path.expanduser()
|
|
92
92
|
if not path.exists():
|
|
93
93
|
raise CliError(f"Path does not exist: {path}")
|
|
@@ -99,7 +99,7 @@ def collect_video_files(input_path: Path, recursive: bool) -> list[tuple[Path, s
|
|
|
99
99
|
if not path.is_dir():
|
|
100
100
|
raise CliError(f"Path is not a regular file or directory: {path}")
|
|
101
101
|
|
|
102
|
-
candidates = path.rglob("*")
|
|
102
|
+
candidates = path.rglob("*")
|
|
103
103
|
files: list[tuple[Path, str]] = []
|
|
104
104
|
for candidate in sorted(candidates):
|
|
105
105
|
if not candidate.is_file():
|
|
@@ -110,8 +110,7 @@ def collect_video_files(input_path: Path, recursive: bool) -> list[tuple[Path, s
|
|
|
110
110
|
files.append((candidate, relative_name))
|
|
111
111
|
|
|
112
112
|
if not files:
|
|
113
|
-
|
|
114
|
-
raise CliError(f"No supported video files found {scope}under: {path}")
|
|
113
|
+
raise CliError(f"No supported video files found under: {path}")
|
|
115
114
|
|
|
116
115
|
return files
|
|
117
116
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: powertech-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: PowerTech CLI for batch video upload and result download.
|
|
5
5
|
Author: PowerTech
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -35,13 +35,13 @@ Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
|
|
|
35
35
|
development SaaS web app:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
POWERTECH_CLI_HOME=~/.powertech/
|
|
38
|
+
POWERTECH_CLI_HOME=~/.powertech/development \
|
|
39
39
|
powertech login --token <development_token> --api-url http://127.0.0.1:8000
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
### Staging
|
|
43
43
|
|
|
44
|
-
Use the same
|
|
44
|
+
Use the same package and save staging credentials separately:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
@@ -66,17 +66,30 @@ is saved with the token, so later upload and download commands do not repeat it.
|
|
|
66
66
|
|
|
67
67
|
## Upload
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
Every upload command requires one camera intrinsics JSON. It is validated locally,
|
|
70
|
+
then applied to every video selected by that command. Directory inputs recursively
|
|
71
|
+
find MP4 and MOV files and preserve relative paths. Interrupted uploads resume from
|
|
72
|
+
local checkpoints automatically.
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
POWERTECH_CLI_HOME=~/.powertech/staging
|
|
74
|
-
|
|
75
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
76
|
+
powertech upload ./video.mp4 --intrinsics ./camera-a.json
|
|
77
|
+
|
|
78
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
79
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
80
|
+
|
|
75
81
|
POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
|
|
76
82
|
```
|
|
77
83
|
|
|
78
84
|
Supported input formats: MP4 and MOV.
|
|
79
85
|
|
|
86
|
+
Run separate commands when videos use different cameras or intrinsics:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
powertech upload ./camera-a-videos --intrinsics ./camera-a.json
|
|
90
|
+
powertech upload ./camera-b-videos --intrinsics ./camera-b.json
|
|
91
|
+
```
|
|
92
|
+
|
|
80
93
|
## Download
|
|
81
94
|
|
|
82
95
|
```bash
|
|
@@ -93,7 +106,7 @@ The isolated mock adapter does not use RDS or OSS:
|
|
|
93
106
|
|
|
94
107
|
```bash
|
|
95
108
|
powertech login --token dev_token --api-url local
|
|
96
|
-
powertech upload ./videos --
|
|
109
|
+
powertech upload ./videos --intrinsics ./camera-a.json
|
|
97
110
|
```
|
|
98
111
|
|
|
99
112
|
CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
|
|
@@ -102,6 +115,6 @@ directory during tests.
|
|
|
102
115
|
## Continuous integration
|
|
103
116
|
|
|
104
117
|
Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
|
|
105
|
-
test.
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
test. Staging does not deploy or require a separate CLI package. A `vX.Y.Z` tag
|
|
119
|
+
whose commit belongs to `main` and whose value matches the package version publishes
|
|
120
|
+
to PyPI through its trusted publisher; manual dispatch remains a recovery path.
|
|
@@ -6,6 +6,7 @@ src/powertech_cli/api.py
|
|
|
6
6
|
src/powertech_cli/checkpoint.py
|
|
7
7
|
src/powertech_cli/config.py
|
|
8
8
|
src/powertech_cli/errors.py
|
|
9
|
+
src/powertech_cli/intrinsics.py
|
|
9
10
|
src/powertech_cli/main.py
|
|
10
11
|
src/powertech_cli/utils.py
|
|
11
12
|
src/powertech_cli.egg-info/PKG-INFO
|
|
@@ -19,4 +20,5 @@ src/powertech_cli/storage/local.py
|
|
|
19
20
|
src/powertech_cli/storage/oss.py
|
|
20
21
|
tests/test_cli_flow.py
|
|
21
22
|
tests/test_downloads.py
|
|
23
|
+
tests/test_intrinsics.py
|
|
22
24
|
tests/test_video_metadata.py
|
|
@@ -25,12 +25,33 @@ class CliFlowTest(unittest.TestCase):
|
|
|
25
25
|
check=False,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
+
def write_intrinsics(self, path: Path, **overrides: object) -> Path:
|
|
29
|
+
parameters: dict[str, object] = {
|
|
30
|
+
"image_width": 1920,
|
|
31
|
+
"image_height": 1080,
|
|
32
|
+
"fx": 918.4,
|
|
33
|
+
"fy": 918.2,
|
|
34
|
+
"cx": 960.0,
|
|
35
|
+
"cy": 540.0,
|
|
36
|
+
"k1": 0.0003,
|
|
37
|
+
"k2": 0.0012,
|
|
38
|
+
"k3": 0.0,
|
|
39
|
+
"k4": 0.0,
|
|
40
|
+
"camera_model": "PinHole",
|
|
41
|
+
"p1": 0.0002,
|
|
42
|
+
"p2": -0.0005,
|
|
43
|
+
}
|
|
44
|
+
parameters.update(overrides)
|
|
45
|
+
path.write_text(json.dumps(parameters), encoding="utf-8")
|
|
46
|
+
return path
|
|
47
|
+
|
|
28
48
|
def test_login_upload_status_and_files(self) -> None:
|
|
29
49
|
with tempfile.TemporaryDirectory() as tmp:
|
|
30
50
|
base = Path(tmp)
|
|
31
51
|
home = base / "home"
|
|
32
52
|
video = base / "kitchen_grasp_0012.mp4"
|
|
33
53
|
video.write_bytes(b"video-data" * 2048)
|
|
54
|
+
intrinsics = self.write_intrinsics(base / "camera-a.json")
|
|
34
55
|
|
|
35
56
|
login = self.run_cli(
|
|
36
57
|
home,
|
|
@@ -46,16 +67,28 @@ class CliFlowTest(unittest.TestCase):
|
|
|
46
67
|
)
|
|
47
68
|
self.assertEqual(login.returncode, 0, login.stderr)
|
|
48
69
|
|
|
49
|
-
upload = self.run_cli(
|
|
70
|
+
upload = self.run_cli(
|
|
71
|
+
home,
|
|
72
|
+
"upload",
|
|
73
|
+
str(video),
|
|
74
|
+
"--intrinsics",
|
|
75
|
+
str(intrinsics),
|
|
76
|
+
"--chunk-size-mb",
|
|
77
|
+
"0.001",
|
|
78
|
+
)
|
|
50
79
|
self.assertEqual(upload.returncode, 0, upload.stderr)
|
|
51
80
|
self.assertIn("UploadedFile=", upload.stdout)
|
|
52
81
|
self.assertIn("relative_path=kitchen_grasp_0012.mp4", upload.stdout)
|
|
82
|
+
self.assertIn("Attached camera-a.json to 1 video file(s)", upload.stdout)
|
|
53
83
|
|
|
54
84
|
state = json.loads((home / "backend-state.json").read_text(encoding="utf-8"))
|
|
55
85
|
upload_batch_id = next(iter(state["upload_batches"].keys()))
|
|
56
86
|
uploaded_file = next(iter(state["uploaded_files"].values()))
|
|
57
87
|
self.assertTrue(uploaded_file["id"].startswith("uf_"))
|
|
58
88
|
self.assertTrue((home / "local-oss" / uploaded_file["object_key"]).exists())
|
|
89
|
+
self.assertEqual(uploaded_file["camera_intrinsics"]["file_name"], "camera-a.json")
|
|
90
|
+
self.assertEqual(uploaded_file["camera_intrinsics_revision"], 1)
|
|
91
|
+
self.assertIsNotNone(uploaded_file["material_submitted_at"])
|
|
59
92
|
|
|
60
93
|
status = self.run_cli(home, "status", upload_batch_id)
|
|
61
94
|
self.assertEqual(status.returncode, 0, status.stderr)
|
|
@@ -83,14 +116,37 @@ class CliFlowTest(unittest.TestCase):
|
|
|
83
116
|
(videos / "a.mp4").write_bytes(b"a")
|
|
84
117
|
(nested / "b.mov").write_bytes(b"b")
|
|
85
118
|
(videos / "notes.txt").write_text("ignored", encoding="utf-8")
|
|
119
|
+
intrinsics = self.write_intrinsics(base / "camera-a.json")
|
|
86
120
|
|
|
87
121
|
login = self.run_cli(home, "login", "--token", "dev_token", "--api-url", "local")
|
|
88
122
|
self.assertEqual(login.returncode, 0, login.stderr)
|
|
89
123
|
|
|
90
|
-
dry_run = self.run_cli(
|
|
124
|
+
dry_run = self.run_cli(
|
|
125
|
+
home,
|
|
126
|
+
"upload",
|
|
127
|
+
str(videos),
|
|
128
|
+
"--intrinsics",
|
|
129
|
+
str(intrinsics),
|
|
130
|
+
"--dry-run",
|
|
131
|
+
)
|
|
91
132
|
self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
|
|
92
133
|
self.assertIn("Matched 2 video file(s)", dry_run.stdout)
|
|
93
134
|
self.assertIn("nested/b.mov", dry_run.stdout)
|
|
135
|
+
self.assertIn("applies to every matched video", dry_run.stdout)
|
|
136
|
+
|
|
137
|
+
def test_invalid_intrinsics_fail_before_upload_state_is_created(self) -> None:
|
|
138
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
139
|
+
base = Path(tmp)
|
|
140
|
+
home = base / "home"
|
|
141
|
+
video = base / "video.mp4"
|
|
142
|
+
video.write_bytes(b"video")
|
|
143
|
+
intrinsics = self.write_intrinsics(base / "bad.json", camera_model="Fisheye")
|
|
144
|
+
|
|
145
|
+
upload = self.run_cli(home, "upload", str(video), "--intrinsics", str(intrinsics))
|
|
146
|
+
|
|
147
|
+
self.assertEqual(upload.returncode, 1)
|
|
148
|
+
self.assertIn("camera_model must be PinHole", upload.stderr)
|
|
149
|
+
self.assertFalse((home / "backend-state.json").exists())
|
|
94
150
|
|
|
95
151
|
|
|
96
152
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from powertech_cli.errors import CliError
|
|
9
|
+
from powertech_cli.intrinsics import load_camera_intrinsics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CameraIntrinsicsTest(unittest.TestCase):
|
|
13
|
+
def valid_parameters(self) -> dict[str, object]:
|
|
14
|
+
return {
|
|
15
|
+
"image_width": 1920,
|
|
16
|
+
"image_height": 1080,
|
|
17
|
+
"fx": 918.4,
|
|
18
|
+
"fy": 918.2,
|
|
19
|
+
"cx": 960,
|
|
20
|
+
"cy": 540,
|
|
21
|
+
"k1": 0,
|
|
22
|
+
"k2": 0,
|
|
23
|
+
"k3": 0,
|
|
24
|
+
"k4": 0,
|
|
25
|
+
"camera_model": "PinHole",
|
|
26
|
+
"p1": 0,
|
|
27
|
+
"p2": 0,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def test_loads_and_normalizes_valid_json(self) -> None:
|
|
31
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
32
|
+
path = Path(tmp) / "camera.json"
|
|
33
|
+
path.write_text(json.dumps(self.valid_parameters()), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
result = load_camera_intrinsics(path)
|
|
36
|
+
|
|
37
|
+
self.assertEqual(result["file_name"], "camera.json")
|
|
38
|
+
self.assertEqual(result["parameters"]["camera_model"], "PinHole")
|
|
39
|
+
self.assertIsInstance(result["parameters"]["fx"], float)
|
|
40
|
+
|
|
41
|
+
def test_rejects_unknown_fields_and_non_finite_numbers(self) -> None:
|
|
42
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
43
|
+
base = Path(tmp)
|
|
44
|
+
unknown = self.valid_parameters()
|
|
45
|
+
unknown["serial_number"] = "camera-a"
|
|
46
|
+
unknown_path = base / "unknown.json"
|
|
47
|
+
unknown_path.write_text(json.dumps(unknown), encoding="utf-8")
|
|
48
|
+
with self.assertRaisesRegex(CliError, "unexpected: serial_number"):
|
|
49
|
+
load_camera_intrinsics(unknown_path)
|
|
50
|
+
|
|
51
|
+
non_finite_path = base / "non-finite.json"
|
|
52
|
+
non_finite_path.write_text(json.dumps({**self.valid_parameters(), "fx": float("nan")}), encoding="utf-8")
|
|
53
|
+
with self.assertRaisesRegex(CliError, "non-finite number"):
|
|
54
|
+
load_camera_intrinsics(non_finite_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|