diff --git a/mapilio_kit/components/geotagging/gps_from_gopro360.py b/mapilio_kit/components/geotagging/gps_from_gopro360.py index 5da6401..49b6ff1 100644 --- a/mapilio_kit/components/geotagging/gps_from_gopro360.py +++ b/mapilio_kit/components/geotagging/gps_from_gopro360.py @@ -129,7 +129,16 @@ def gopro360max_stitch(video_file: str, frame_rate: int, output_folder: str, quality: str, - bin_dir: str): + bin_dir: str) -> None: + """Extract, stitch, and geotag frames from a GoPro MAX 360 video. + + Stitches dual-fisheye tracks into equirectangular JPEGs using + MAX2spherebatch, geotagges them from the video's GPS track, and + writes XMP projection metadata so panorama viewers can display them + correctly. Crop parameters are computed dynamically from the actual + image dimensions, so any GoPro MAX recording resolution is handled + without hard-coding pixel counts. + """ script_dir = os.path.dirname(os.path.realpath(__file__)) frame_delta = 1.0 / frame_rate print(f"frame_delta: {frame_delta}") @@ -219,16 +228,26 @@ def gopro360max_stitch(video_file: str, print(f"cmd: {cmd}") run_command(cmd, show_progress=False) - cmd = f"exiftool -CameraElevationAngle=360 " \ - f"-make=GoPro " \ - f"-model=Max " \ - f"-ProjectionType=equirectangular " \ - f"-UsePanoramaViewer=True " \ - f"-CroppedAreaImageWidthPixels=4096 " \ - f"-CroppedAreaImageHeightPixels=1344 " \ - f"-FullPanoWidthPixels=4096 " \ - f"-FullPanoHeightPixels=1344 " \ - f"-CroppedAreaLeftPixels=0 -CroppedAreaTopPixels=0 {frames_folder}" + # Use exiftool computed-value syntax to derive crop parameters from + # the actual image dimensions. Hard-coding 4096×1344 only works for + # one specific GoPro Max recording mode; this adapts to any output + # resolution (e.g. 4096×2048 for full-equirectangular exports). + # CroppedAreaTopPixels = (ImageWidth/2 - ImageHeight) / 2 + cmd = ( + f"exiftool -CameraElevationAngle=360 " + f"-make=GoPro " + f"-model=Max " + f"-ProjectionType=equirectangular " + f"-UsePanoramaViewer=True " + f"'-CroppedAreaImageWidthPixels<$ImageWidth' " + f"'-CroppedAreaImageHeightPixels<$ImageHeight' " + f"'-FullPanoWidthPixels<$ImageWidth' " + f"'-FullPanoHeightPixels<$ImageHeight' " + f"-CroppedAreaLeftPixels=0 " + f"'-CroppedAreaTopPixels<${{ImageWidth;$_=($_/2" + f"-$self->GetValue(\"ImageHeight\"))/2}}' " + f"{frames_folder}" + ) print(f"cmd: {cmd}") run_command(cmd, show_progress=False) remove_files(frames_folder, "*.jpg_original") diff --git a/mapilio_kit/components/upload/upload_manager.py b/mapilio_kit/components/upload/upload_manager.py index 46536a9..7e4bbcd 100644 --- a/mapilio_kit/components/upload/upload_manager.py +++ b/mapilio_kit/components/upload/upload_manager.py @@ -64,9 +64,12 @@ def upload( while True: chunk = data.read(chunk_size) files = {'chunk': (self.session_key, chunk, "multipart/form-data")} + # RFC 7233: Content-Range end is the last byte of the current chunk + # (inclusive, 0-indexed), not the total entity size. + chunk_end = (offset + len(chunk) - 1) if chunk else self.entity_size headers = { 'Connection': "keep-alive", - "content-range": f"bytes={offset}-{self.entity_size}/{self.entity_size}", + "content-range": f"bytes={offset}-{chunk_end}/{self.entity_size}", "X-File-Id": self.session_key, "Content-Length": str(self.entity_size - offset), "email": email, diff --git a/tests/test_upload_manager.py b/tests/test_upload_manager.py new file mode 100644 index 0000000..5d7e378 --- /dev/null +++ b/tests/test_upload_manager.py @@ -0,0 +1,86 @@ +"""Unit tests for UploadManager.upload() Content-Range header construction. + +RFC 7233 §4.2 requires: Content-Range: bytes -/ +where is the last byte index of the current chunk (0-indexed, inclusive), +NOT the total entity size. +""" + +from __future__ import annotations + +import io +from unittest.mock import MagicMock, patch + +import pytest + +from mapilio_kit.components.upload.upload_manager import UploadManager + +USER_ITEMS = {"SettingsEmail": "test@example.com"} + + +def _make_manager(entity_size: int) -> UploadManager: + return UploadManager( + user_access_token="test_token", + session_key="test.zip", + entity_size=entity_size, + ) + + +def _mock_get(): + """Stub for fetch_offset — returns 0 (fresh upload).""" + m = MagicMock() + m.raise_for_status.return_value = None + m.json.return_value = {"totalChunkUploaded": 0} + return m + + +def _mock_post_json(hash_value: str = "abc123"): + """Stub POST response that satisfies the finalization return path.""" + m = MagicMock() + m.status_code = 200 + m.raise_for_status.return_value = None + m.headers = {"content-type": "application/json"} + m.text = f'{{"hash": "{hash_value}"}}' + return m + + +@patch("mapilio_kit.components.upload.upload_manager.requests.post") +@patch("mapilio_kit.components.upload.upload_manager.requests.get") +def test_single_chunk_content_range(mock_get, mock_post): + """Single chunk: Content-Range end must be last byte index, not total size.""" + total = 100 + mock_get.return_value = _mock_get() + mock_post.return_value = _mock_post_json() + + manager = _make_manager(total) + manager.upload(USER_ITEMS, io.BytesIO(b"x" * total)) + + ranges = [ + call.kwargs["headers"]["content-range"] + for call in mock_post.call_args_list + if "content-range" in call.kwargs.get("headers", {}) + ] + # First (data) chunk: bytes=0-99/100 + assert "bytes=0-99/100" in ranges, f"unexpected ranges: {ranges}" + + +@patch("mapilio_kit.components.upload.upload_manager.requests.post") +@patch("mapilio_kit.components.upload.upload_manager.requests.get") +def test_multi_chunk_content_range(mock_get, mock_post): + """Multi-chunk upload: each chunk carries the correct byte range.""" + total = 150 + chunk_size = 100 + mock_get.return_value = _mock_get() + mock_post.return_value = _mock_post_json() + + manager = _make_manager(total) + manager.upload(USER_ITEMS, io.BytesIO(b"x" * total), chunk_size=chunk_size) + + ranges = [ + call.kwargs["headers"]["content-range"] + for call in mock_post.call_args_list + if "content-range" in call.kwargs.get("headers", {}) + ] + assert "bytes=0-99/150" in ranges, f"unexpected ranges: {ranges}" + assert "bytes=100-149/150" in ranges, f"unexpected ranges: {ranges}" + # Verify the old wrong value (end == total) is NOT sent for data chunks + assert "bytes=0-150/150" not in ranges, "old incorrect range still present"