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.
Files changed (26) hide show
  1. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/PKG-INFO +24 -11
  2. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/README.md +23 -10
  3. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/pyproject.toml +1 -1
  4. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/__init__.py +1 -1
  5. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/api.py +52 -0
  6. powertech_cli-0.2.0/src/powertech_cli/intrinsics.py +81 -0
  7. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/main.py +32 -15
  8. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/utils.py +3 -4
  9. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/PKG-INFO +24 -11
  10. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/SOURCES.txt +2 -0
  11. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/tests/test_cli_flow.py +58 -2
  12. powertech_cli-0.2.0/tests/test_intrinsics.py +58 -0
  13. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/setup.cfg +0 -0
  14. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/__main__.py +0 -0
  15. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/checkpoint.py +0 -0
  16. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/config.py +0 -0
  17. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/errors.py +0 -0
  18. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/__init__.py +0 -0
  19. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/local.py +0 -0
  20. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli/storage/oss.py +0 -0
  21. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/dependency_links.txt +0 -0
  22. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/entry_points.txt +0 -0
  23. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/requires.txt +0 -0
  24. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/src/powertech_cli.egg-info/top_level.txt +0 -0
  25. {powertech_cli-0.1.0 → powertech_cli-0.2.0}/tests/test_downloads.py +0 -0
  26. {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.1.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/dev \
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 editable installation and save staging credentials separately:
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
- Each command creates one upload batch. Directory uploads preserve relative paths,
70
- and `--resume` retains the batch and completed OSS parts after interruption.
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 powertech upload ./video.mp4 --resume
74
- POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
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 --recursive --resume
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. The packaging check is release preparation; staging does not deploy or
106
- require a separate CLI package. PyPI publication is a separate, manually
107
- triggered GitHub Actions workflow.
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/dev \
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 editable installation and save staging credentials separately:
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
- Each command creates one upload batch. Directory uploads preserve relative paths,
57
- and `--resume` retains the batch and completed OSS parts after interruption.
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 powertech upload ./video.mp4 --resume
61
- POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
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 --recursive --resume
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. The packaging check is release preparation; staging does not deploy or
93
- require a separate CLI package. PyPI publication is a separate, manually
94
- triggered GitHub Actions workflow.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "powertech-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "PowerTech CLI for batch video upload and result download."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """PowerTech CLI upload tool."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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("--recursive", action="store_true", help="Upload supported videos in nested directories")
48
- upload.add_argument("--resume", action="store_true", help="Reuse checkpoint for interrupted uploads")
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
- config = load_config()
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), recursive=args.recursive))
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) if args.resume else None
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) if resume else None
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=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, recursive: bool) -> list[tuple[Path, str]]:
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("*") if recursive else path.iterdir()
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
- scope = "recursively " if recursive else ""
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.1.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/dev \
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 editable installation and save staging credentials separately:
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
- Each command creates one upload batch. Directory uploads preserve relative paths,
70
- and `--resume` retains the batch and completed OSS parts after interruption.
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 powertech upload ./video.mp4 --resume
74
- POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
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 --recursive --resume
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. The packaging check is release preparation; staging does not deploy or
106
- require a separate CLI package. PyPI publication is a separate, manually
107
- triggered GitHub Actions workflow.
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(home, "upload", str(video), "--resume", "--chunk-size-mb", "0.001")
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(home, "upload", str(videos), "--recursive", "--dry-run")
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