From 595fd48f08bda6acd80603a89bf7c956bd8065b0 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 5 Oct 2026 14:52:36 -0300 Subject: [PATCH 1/3] [ar-api] Restore CLI video segment annotation (VID-59) --- CLI-COMMANDS.md | 23 +++- roboflow/cli/handlers/video.py | 104 ++++++++++++++- tests/cli/test_video_handler.py | 221 +++++++++++++++++++++++++++++++- 3 files changed, 341 insertions(+), 7 deletions(-) diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index ccf463a4..8dd4bbbe 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -471,7 +471,7 @@ roboflow workspace stats --start-date 2026-01-01 --end-date 2026-03-31 roboflow universe search "hard hats" --type dataset --limit 5 ``` -### Native video upload and status +### Native video upload and segment annotation Action Recognition projects take whole videos as Sources. `video upload` streams the original MP4/MOV bytes without re-encoding, then reports the **canonical video ID** to use @@ -495,8 +495,25 @@ roboflow video upload-status aBcD1234 -p my-ar-project --json roboflow video upload-status aBcD1234 -p my-ar-project --wait --poll-timeout 120 ``` +```bash +# 3. Annotate segments from a complete roboflow-video-coco document. +# The file is forwarded unchanged, so native frame indices, PTS and +# rational time bases are preserved exactly as authored. +roboflow video annotate -p my-ar-project -i aBcD1234 -a segments.json --json +# { "success": true, "inDataset": true, "createdClasses": ["walking"] } +``` + +In `segments.json`, `segments` belongs at the document top level and +`videos[0].time_base` is a rational object such as +`{"numerator": 1, "denominator": 15360}`. `images` and `annotations` may be +omitted; if supplied, each must be an empty array. Use the original video's +probed PTS values rather than deriving them from frame indices or nominal FPS. + Upload accepts `-b/--batch`, `-t/--tag` (comma-separated), `--metadata` (JSON object) and -`-s/--split`. +`-s/--split`. Annotate defaults to the API behaviour of adding the Source to the Dataset; +override with `--no-add-to-dataset`, set the split with `-s/--split`, and pass `--overwrite` +to replace segments that already differ (otherwise a conflicting save is rejected and an +identical re-submit succeeds). Exit codes follow the CLI contract: `0` success, `1` error, `2` auth, `3` not found. A `failed` ingestion state and a `--wait` timeout both exit nonzero; the timeout message names @@ -600,7 +617,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between | `asynctasks` | Inspect async background tasks (e.g. project forks) | | `trash` | List items in Trash | | `universe` | Search Roboflow Universe | -| `video` | Native video upload/status and video inference | +| `video` | Native video upload/status/annotation, and video inference | | `batch` | Batch processing jobs *(coming soon)* | | `completion` | Install or generate shell completion scripts (bash, zsh, fish) | diff --git a/roboflow/cli/handlers/video.py b/roboflow/cli/handlers/video.py index 99ba621e..5a8a747f 100644 --- a/roboflow/cli/handlers/video.py +++ b/roboflow/cli/handlers/video.py @@ -1,4 +1,4 @@ -"""Video commands: native video Source ingestion and legacy video inference.""" +"""Video commands: native video Source ingestion/annotation and legacy video inference.""" from __future__ import annotations @@ -10,7 +10,7 @@ video_app = typer.Typer( cls=SortedGroup, - help="Native video upload and video inference operations", + help="Native video upload/annotation and video inference operations", no_args_is_help=True, ) @@ -59,7 +59,7 @@ def upload( ) -> None: """Upload original video bytes as a native video Source. - Streams the file unchanged and reports the canonical video ID. + Streams the file unchanged and reports the canonical video ID to annotate. """ args = ctx_to_args( ctx, @@ -101,6 +101,43 @@ def upload_status( _video_upload_status(args) +@video_app.command("annotate") +def annotate( + ctx: typer.Context, + annotation_file: Annotated[ + str, typer.Option("-a", "--annotation-file", help="Path to a complete roboflow-video-coco JSON file") + ], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID, or workspace/project")], + video_id: Annotated[str, typer.Option("-i", "--video-id", help="Canonical video ID from 'video upload'")], + add_to_dataset: Annotated[ + Optional[bool], + typer.Option( + "--add-to-dataset/--no-add-to-dataset", + help="Override the API default of adding the Source to the Dataset", + ), + ] = None, + overwrite: Annotated[ + bool, typer.Option("--overwrite", help="Replace different existing segments on this video") + ] = False, + split: Annotated[Optional[str], typer.Option("-s", "--split", help="Dataset split: train, valid or test")] = None, +) -> None: + """Annotate a native video Source's segments from a video-coco file. + + The file is read whole and forwarded unchanged, so native frame indices, + PTS and rational time bases survive exactly as authored. + """ + args = ctx_to_args( + ctx, + annotation_file=annotation_file, + project=project, + video_id=video_id, + add_to_dataset=add_to_dataset, + overwrite=overwrite, + split=split, + ) + _video_annotate(args) + + # --------------------------------------------------------------------------- # Business logic (unchanged from argparse version) # --------------------------------------------------------------------------- @@ -361,3 +398,64 @@ def _video_upload_status(args) -> None: # noqa: ANN001 return _emit_upload_status(args, status) + + +def _video_annotate(args) -> None: # noqa: ANN001 + import json as json_mod + + from roboflow.adapters.rfapi import AnnotationSaveError + from roboflow.cli._output import output, output_error + + try: + with open(args.annotation_file) as handle: + document = json_mod.load(handle) + except OSError as exc: + output_error(args, f"Cannot read annotation file: {exc}", hint="Pass the path to a video-coco JSON file.") + return + except json_mod.JSONDecodeError as exc: + output_error( + args, + f"Invalid JSON in {args.annotation_file}: {exc}", + hint="The file must be one complete roboflow-video-coco document.", + ) + return + + if not isinstance(document, dict): + output_error( + args, + f"{args.annotation_file} must contain a JSON object.", + hint="The file must be one complete roboflow-video-coco document.", + ) + return + + project = _load_project(args) + if project is None: + return + + try: + # `document` is forwarded as parsed: no re-encoding of frames, PTS or time bases. + result = project.annotate_video_segments( + args.video_id, + document, + overwrite=args.overwrite, + split=args.split, + add_to_dataset=args.add_to_dataset, + ) + except AnnotationSaveError as exc: + status_code = getattr(exc, "status_code", None) + if status_code == 409: + hint = "Different segments already exist on this video. Re-run with --overwrite to replace them." + elif status_code == 404: + hint = "Check the canonical video ID from 'roboflow video upload'." + else: + hint = "Check that the document is a complete video-coco with at least one segment." + output_error(args, str(exc), hint=hint, exit_code=3 if status_code == 404 else 1) + return + + lines = [f"Annotated video {args.video_id}."] + if "inDataset" in result: + lines.append(f"In dataset: {'yes' if result.get('inDataset') else 'no'}") + created = result.get("createdClasses") + if created: + lines.append(f"Created classes: {', '.join(map(str, created))}") + output(args, result, text="\n".join(lines)) diff --git a/tests/cli/test_video_handler.py b/tests/cli/test_video_handler.py index ad480502..bf2b1c97 100644 --- a/tests/cli/test_video_handler.py +++ b/tests/cli/test_video_handler.py @@ -78,6 +78,52 @@ def test_status_passes_job_id_to_api(self, _mock_key, mock_api) -> None: } } +# A complete video-coco document in the shape the import schema accepts, taken +# from a real MOV: `segments` is top level, `time_base` is a rational object, +# and `images`/`annotations` stay empty. Tests assert it reaches the SDK with +# every value identical. +VIDEO_COCO_DOCUMENT = { + "info": {"format": "roboflow-video-coco"}, + "videos": [ + { + "id": 1, + "file_name": "clip.mov", + "width": 1620, + "height": 1080, + "duration": 4.566667, + "fps": 30, + "frame_count": 137, + "time_base": {"numerator": 1, "denominator": 15360}, + } + ], + "categories": [{"id": 1, "name": "hand_gesture"}], + # Real MOV presentation timestamps are not frame_index * ticks_per_frame, + # so they must survive the read exactly rather than being recomputed. + "segments": [ + { + "id": 1, + "video_id": 1, + "category_id": 1, + "start_frame": 5, + "end_frame": 64, + "start_pts": 3067, + "end_pts": 33275, + } + ], + "images": [], + "annotations": [], +} + + +def _write_json(directory, name, payload): + import json as json_mod + import os + + path = os.path.join(directory, name) + with open(path, "w") as handle: + json_mod.dump(payload, handle) + return path + class NativeVideoCliTest(unittest.TestCase): """Shared fixtures that let real command dispatch build a real Project.""" @@ -108,7 +154,7 @@ class TestNativeVideoRegistration(NativeVideoCliTest): """The real CLI exposes the native video commands alongside inference.""" def test_native_commands_are_registered(self) -> None: - for command in ("upload", "upload-status"): + for command in ("upload", "upload-status", "annotate"): with self.subTest(command=command): result = runner.invoke(app, ["video", command, "--help"]) self.assertEqual(result.exit_code, 0) @@ -383,6 +429,179 @@ def test_failed_state_exits_nonzero(self, mock_status) -> None: self.assertNotEqual(result.exit_code, 0) +class TestVideoAnnotate(NativeVideoCliTest): + """`roboflow video annotate` forwards the video-coco document unchanged.""" + + def setUp(self) -> None: + super().setUp() + self.document_path = _write_json(self.tmp.name, "segments.json", VIDEO_COCO_DOCUMENT) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_document_and_defaults_are_forwarded_unchanged(self, mock_annotate) -> None: + mock_annotate.return_value = {"success": True, "inDataset": True, "createdClasses": ["walking"]} + + result = runner.invoke( + app, + ["video", "annotate", "-p", self.project_ref, "-i", "source-9", "-a", self.document_path], + ) + + self.assertEqual(result.exit_code, 0, result.output) + mock_annotate.assert_called_once_with( + "source-9", + VIDEO_COCO_DOCUMENT, + overwrite=False, + split=None, + add_to_dataset=None, + ) + # Native frame/PTS/time-base values survive the read untouched. + sent = mock_annotate.call_args.args[1] + segment = sent["segments"][0] + self.assertEqual(segment["start_frame"], 5) + self.assertEqual(segment["end_frame"], 64) + self.assertEqual(segment["start_pts"], 3067) + self.assertEqual(segment["end_pts"], 33275) + video = sent["videos"][0] + self.assertEqual(video["time_base"], {"numerator": 1, "denominator": 15360}) + self.assertEqual(video["duration"], 4.566667) + self.assertEqual(video["frame_count"], 137) + self.assertIn("walking", result.output) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_explicit_overwrite_split_and_membership_are_forwarded(self, mock_annotate) -> None: + mock_annotate.return_value = {"success": True, "inDataset": False} + + result = runner.invoke( + app, + [ + "video", + "annotate", + "-p", + self.project_ref, + "-i", + "source-9", + "-a", + self.document_path, + "--overwrite", + "-s", + "test", + "--no-add-to-dataset", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + mock_annotate.assert_called_once_with( + "source-9", + VIDEO_COCO_DOCUMENT, + overwrite=True, + split="test", + add_to_dataset=False, + ) + self.assertIn("In dataset: no", result.output) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_add_to_dataset_true_is_explicit(self, mock_annotate) -> None: + mock_annotate.return_value = {"success": True, "inDataset": True} + + runner.invoke( + app, + ["video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path, "--add-to-dataset"], + ) + + self.assertEqual(mock_annotate.call_args.kwargs["add_to_dataset"], True) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_json_output_is_the_server_response(self, mock_annotate) -> None: + mock_annotate.return_value = {"success": True, "inDataset": True, "createdClasses": []} + + result = runner.invoke( + app, + ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output), {"success": True, "inDataset": True, "createdClasses": []}) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_conflict_suggests_overwrite(self, mock_annotate) -> None: + from roboflow.adapters.rfapi import AnnotationSaveError + + mock_annotate.side_effect = AnnotationSaveError("segments preserved", status_code=409) + result = runner.invoke( + app, + ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("--overwrite", json.loads(result.output)["error"]["hint"]) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_zero_segment_rejection_is_reported(self, mock_annotate) -> None: + from roboflow.adapters.rfapi import AnnotationSaveError + + mock_annotate.side_effect = AnnotationSaveError("segments must not be empty", status_code=400) + result = runner.invoke( + app, + ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("segments must not be empty", json.loads(result.output)["error"]["message"]) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_unknown_video_exits_not_found(self, mock_annotate) -> None: + from roboflow.adapters.rfapi import AnnotationSaveError + + mock_annotate.side_effect = AnnotationSaveError("source not found", status_code=404) + result = runner.invoke( + app, + ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], + ) + + self.assertEqual(result.exit_code, 3) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_malformed_json_never_reaches_the_api(self, mock_annotate) -> None: + bad = os.path.join(self.tmp.name, "bad.json") + with open(bad, "w") as handle: + handle.write('{"annotations": ') + + result = runner.invoke(app, ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", bad]) + + self.assertNotEqual(result.exit_code, 0) + mock_annotate.assert_not_called() + self.mock_get_project.assert_not_called() + self.assertIn("Invalid JSON", json.loads(result.output)["error"]["message"]) + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_non_object_document_is_rejected(self, mock_annotate) -> None: + listed = _write_json(self.tmp.name, "list.json", [1, 2, 3]) + + result = runner.invoke(app, ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", listed]) + + self.assertNotEqual(result.exit_code, 0) + mock_annotate.assert_not_called() + + @patch("roboflow.core.project.Project.annotate_video_segments") + def test_missing_file_never_reaches_the_api(self, mock_annotate) -> None: + result = runner.invoke( + app, + [ + "--json", + "video", + "annotate", + "-p", + self.project_ref, + "-i", + "s1", + "-a", + os.path.join(self.tmp.name, "absent.json"), + ], + ) + + self.assertNotEqual(result.exit_code, 0) + mock_annotate.assert_not_called() + + class TestLegacyVideoContractsIntact(unittest.TestCase): """The native commands must not disturb legacy video inference.""" From 2508af69aaa7bac1b50610acb4e4c8585ca53c28 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 5 Oct 2026 15:16:44 -0300 Subject: [PATCH 2/3] [ar-api] Map video annotate API errors to the CLI exit-code contract (VID-59) Route annotate rejections through output_api_error so a rejected key exits 2, and give the video-coco hint only to 400s instead of transport failures. Fold the annotate CLI tests into table-driven cases. Co-Authored-By: Claude Opus 5.5 --- roboflow/cli/handlers/video.py | 20 ++--- tests/cli/test_video_handler.py | 126 ++++++++++---------------------- 2 files changed, 51 insertions(+), 95 deletions(-) diff --git a/roboflow/cli/handlers/video.py b/roboflow/cli/handlers/video.py index 35fdc16e..e1bf4086 100644 --- a/roboflow/cli/handlers/video.py +++ b/roboflow/cli/handlers/video.py @@ -396,7 +396,7 @@ def _video_annotate(args) -> None: # noqa: ANN001 import json as json_mod from roboflow.adapters.rfapi import AnnotationSaveError - from roboflow.cli._output import output, output_error + from roboflow.cli._output import output, output_api_error, output_error try: with open(args.annotation_file) as handle: @@ -434,14 +434,16 @@ def _video_annotate(args) -> None: # noqa: ANN001 add_to_dataset=args.add_to_dataset, ) except AnnotationSaveError as exc: - status_code = getattr(exc, "status_code", None) - if status_code == 409: - hint = "Different segments already exist on this video. Re-run with --overwrite to replace them." - elif status_code == 404: - hint = "Check the canonical video ID from 'roboflow video upload'." - else: - hint = "Check that the document is a complete video-coco with at least one segment." - output_error(args, str(exc), hint=hint, exit_code=3 if status_code == 404 else 1) + hints = { + 400: "Check that the document is a complete video-coco with at least one segment.", + 409: "Different segments already exist on this video. Re-run with --overwrite to replace them.", + } + output_api_error( + args, + exc, + hint=hints.get(exc.status_code), + not_found_hint="Check the canonical video ID from 'roboflow video upload'.", + ) return lines = [f"Annotated video {args.video_id}."] diff --git a/tests/cli/test_video_handler.py b/tests/cli/test_video_handler.py index 1ee3007f..3733adfc 100644 --- a/tests/cli/test_video_handler.py +++ b/tests/cli/test_video_handler.py @@ -442,17 +442,6 @@ def test_document_and_defaults_are_forwarded_unchanged(self, mock_annotate) -> N split=None, add_to_dataset=None, ) - # Native frame/PTS/time-base values survive the read untouched. - sent = mock_annotate.call_args.args[1] - segment = sent["segments"][0] - self.assertEqual(segment["start_frame"], 5) - self.assertEqual(segment["end_frame"], 64) - self.assertEqual(segment["start_pts"], 3067) - self.assertEqual(segment["end_pts"], 33275) - video = sent["videos"][0] - self.assertEqual(video["time_base"], {"numerator": 1, "denominator": 15360}) - self.assertEqual(video["duration"], 4.566667) - self.assertEqual(video["frame_count"], 137) self.assertIn("walking", result.output) @patch("roboflow.core.project.Project.annotate_video_segments") @@ -487,16 +476,12 @@ def test_explicit_overwrite_split_and_membership_are_forwarded(self, mock_annota ) self.assertIn("In dataset: no", result.output) - @patch("roboflow.core.project.Project.annotate_video_segments") - def test_add_to_dataset_true_is_explicit(self, mock_annotate) -> None: - mock_annotate.return_value = {"success": True, "inDataset": True} - + mock_annotate.reset_mock() runner.invoke( app, ["video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path, "--add-to-dataset"], ) - - self.assertEqual(mock_annotate.call_args.kwargs["add_to_dataset"], True) + self.assertIs(mock_annotate.call_args.kwargs["add_to_dataset"], True) @patch("roboflow.core.project.Project.annotate_video_segments") def test_json_output_is_the_server_response(self, mock_annotate) -> None: @@ -511,84 +496,53 @@ def test_json_output_is_the_server_response(self, mock_annotate) -> None: self.assertEqual(json.loads(result.output), {"success": True, "inDataset": True, "createdClasses": []}) @patch("roboflow.core.project.Project.annotate_video_segments") - def test_conflict_suggests_overwrite(self, mock_annotate) -> None: - from roboflow.adapters.rfapi import AnnotationSaveError - - mock_annotate.side_effect = AnnotationSaveError("segments preserved", status_code=409) - result = runner.invoke( - app, - ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], - ) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn("--overwrite", json.loads(result.output)["error"]["hint"]) - - @patch("roboflow.core.project.Project.annotate_video_segments") - def test_zero_segment_rejection_is_reported(self, mock_annotate) -> None: + def test_api_rejections_follow_exit_code_contract(self, mock_annotate) -> None: from roboflow.adapters.rfapi import AnnotationSaveError - mock_annotate.side_effect = AnnotationSaveError("segments must not be empty", status_code=400) - result = runner.invoke( - app, - ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn("segments must not be empty", json.loads(result.output)["error"]["message"]) - - @patch("roboflow.core.project.Project.annotate_video_segments") - def test_unknown_video_exits_not_found(self, mock_annotate) -> None: - from roboflow.adapters.rfapi import AnnotationSaveError + cases = [ + (409, 1, "--overwrite"), + (400, 1, "at least one segment"), + (404, 3, "canonical video ID"), + (401, 2, "ROBOFLOW_API_KEY"), + (None, 1, None), # transport failure: no status, no document hint + ] + for status_code, exit_code, hint in cases: + with self.subTest(status_code=status_code): + mock_annotate.side_effect = AnnotationSaveError("server said no", status_code=status_code) - mock_annotate.side_effect = AnnotationSaveError("source not found", status_code=404) - result = runner.invoke( - app, - ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], - ) + result = runner.invoke( + app, + ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", self.document_path], + ) - self.assertEqual(result.exit_code, 3) + self.assertEqual(result.exit_code, exit_code) + error = json.loads(result.output)["error"] + self.assertEqual(error["message"], "server said no") + if hint is None: + self.assertNotIn("hint", error) + else: + self.assertIn(hint, error["hint"]) @patch("roboflow.core.project.Project.annotate_video_segments") - def test_malformed_json_never_reaches_the_api(self, mock_annotate) -> None: - bad = os.path.join(self.tmp.name, "bad.json") - with open(bad, "w") as handle: - handle.write('{"annotations": ') - - result = runner.invoke(app, ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", bad]) + def test_unreadable_document_never_reaches_the_api(self, mock_annotate) -> None: + malformed = os.path.join(self.tmp.name, "bad.json") + with open(malformed, "w") as handle: + handle.write('{"segments": ') + cases = [ + (malformed, "Invalid JSON"), + (_write_json(self.tmp.name, "list.json", [1, 2, 3]), "must contain a JSON object"), + (os.path.join(self.tmp.name, "absent.json"), "Cannot read annotation file"), + ] + for path, message in cases: + with self.subTest(message=message): + result = runner.invoke( + app, ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", path] + ) - self.assertNotEqual(result.exit_code, 0) + self.assertEqual(result.exit_code, 1) + self.assertIn(message, json.loads(result.output)["error"]["message"]) mock_annotate.assert_not_called() self.mock_get_project.assert_not_called() - self.assertIn("Invalid JSON", json.loads(result.output)["error"]["message"]) - - @patch("roboflow.core.project.Project.annotate_video_segments") - def test_non_object_document_is_rejected(self, mock_annotate) -> None: - listed = _write_json(self.tmp.name, "list.json", [1, 2, 3]) - - result = runner.invoke(app, ["--json", "video", "annotate", "-p", self.project_ref, "-i", "s1", "-a", listed]) - - self.assertNotEqual(result.exit_code, 0) - mock_annotate.assert_not_called() - - @patch("roboflow.core.project.Project.annotate_video_segments") - def test_missing_file_never_reaches_the_api(self, mock_annotate) -> None: - result = runner.invoke( - app, - [ - "--json", - "video", - "annotate", - "-p", - self.project_ref, - "-i", - "s1", - "-a", - os.path.join(self.tmp.name, "absent.json"), - ], - ) - - self.assertNotEqual(result.exit_code, 0) - mock_annotate.assert_not_called() class TestLegacyVideoContractsIntact(unittest.TestCase): From 9ac17aae53e904850e542e4e860adbf7fb806760 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 5 Oct 2026 15:24:08 -0300 Subject: [PATCH 3/3] [ar-api] Read video-coco annotation files as UTF-8 (VID-59) Without an explicit encoding, Windows decodes the file with its locale codepage and silently corrupts non-ASCII class names. A file in another encoding (e.g. UTF-16 from PowerShell redirection) now gets an actionable hint. Co-Authored-By: Claude Opus 5.5 --- roboflow/cli/handlers/video.py | 10 +++++++++- tests/cli/test_video_handler.py | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/roboflow/cli/handlers/video.py b/roboflow/cli/handlers/video.py index e1bf4086..348220b7 100644 --- a/roboflow/cli/handlers/video.py +++ b/roboflow/cli/handlers/video.py @@ -399,11 +399,19 @@ def _video_annotate(args) -> None: # noqa: ANN001 from roboflow.cli._output import output, output_api_error, output_error try: - with open(args.annotation_file) as handle: + # Explicit UTF-8: the locale default (cp1252 on Windows) would silently corrupt non-ASCII class names. + with open(args.annotation_file, encoding="utf-8") as handle: document = json_mod.load(handle) except OSError as exc: output_error(args, f"Cannot read annotation file: {exc}", hint="Pass the path to a video-coco JSON file.") return + except UnicodeDecodeError as exc: + output_error( + args, + f"{args.annotation_file} is not UTF-8: {exc}", + hint="Save the video-coco document as UTF-8 JSON.", + ) + return except json_mod.JSONDecodeError as exc: output_error( args, diff --git a/tests/cli/test_video_handler.py b/tests/cli/test_video_handler.py index 3733adfc..3bf548aa 100644 --- a/tests/cli/test_video_handler.py +++ b/tests/cli/test_video_handler.py @@ -528,8 +528,12 @@ def test_unreadable_document_never_reaches_the_api(self, mock_annotate) -> None: malformed = os.path.join(self.tmp.name, "bad.json") with open(malformed, "w") as handle: handle.write('{"segments": ') + utf16 = os.path.join(self.tmp.name, "utf16.json") + with open(utf16, "w", encoding="utf-16") as handle: # what Windows PowerShell 5.1 redirection writes + json.dump(VIDEO_COCO_DOCUMENT, handle) cases = [ (malformed, "Invalid JSON"), + (utf16, "is not UTF-8"), (_write_json(self.tmp.name, "list.json", [1, 2, 3]), "must contain a JSON object"), (os.path.join(self.tmp.name, "absent.json"), "Cannot read annotation file"), ]