Skip to content

Commit 8b84edf

Browse files
authored
[ar-api] Upload native Action Recognition videos (VID-35) (#535)
* [ar-api] Upload native videos through the Python SDK * Bound native video upload status requests
1 parent fc554e4 commit 8b84edf

4 files changed

Lines changed: 462 additions & 0 deletions

File tree

‎docs/core/project.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,37 @@
11
:::roboflow.core.project
2+
3+
## Upload a native Action Recognition video
4+
5+
`Project.upload_video` sends the original MP4 or MOV bytes to a signed upload
6+
URL. It creates a video Source in the project; it does not extract frames or
7+
run inference. The platform processes the upload asynchronously.
8+
9+
```python
10+
project = rf.workspace("my-workspace").project("my-actions")
11+
status = project.upload_video(
12+
"clip.mp4",
13+
batch_name="session-1",
14+
tag_names=["indoor"],
15+
metadata={"camera": "front"},
16+
split="train",
17+
)
18+
if status["status"] == "pending":
19+
status = project.wait_for_video_upload(status["videoId"], poll_timeout=300)
20+
21+
if status["status"] == "failed":
22+
raise RuntimeError(status["message"])
23+
24+
source_id = status["videoId"] # Use this Source ID for video annotations.
25+
```
26+
27+
`upload_video(..., wait=True)` performs the bounded wait in one call. The
28+
returned status is the API response: `pending`, `uploaded` (with
29+
`resolvedBatch`), or `failed` (with `message`). Poll later with
30+
`project.get_video_upload_status(video_id)`. Always use `videoId` from the
31+
final `uploaded` response because ingestion can deduplicate onto another
32+
Source. Batch, tags, metadata, and split follow the platform upload API;
33+
the API validates their values. A timeout leaves the upload running, so
34+
poll its original ID later. `poll_timeout=0` makes one status request and
35+
returns a terminal result if available. Status requests use the remaining
36+
polling budget as their connection and read inactivity timeout; this is not
37+
a strict whole-response wall-clock limit for a slowly streaming server.

‎roboflow/adapters/rfapi.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,62 @@ def _save_annotation_error(response):
910910
return AnnotationSaveError(str(responsejson), status_code=response.status_code)
911911

912912

913+
# ---------------------------------------------------------------------------
914+
# Native video upload endpoints
915+
# ---------------------------------------------------------------------------
916+
917+
VIDEO_UPLOAD_PREPARE_TIMEOUT = (5, 30)
918+
VIDEO_UPLOAD_STATUS_TIMEOUT = 30
919+
920+
921+
def prepare_video_upload(api_key, workspace_url, project_url, body) -> dict:
922+
"""Prepare a native video Source upload and obtain its signed PUT URL."""
923+
try:
924+
response = requests.post(
925+
f"{API_URL}/{workspace_url}/upload/video",
926+
params={"api_key": api_key},
927+
json={"project": project_url, **body},
928+
timeout=VIDEO_UPLOAD_PREPARE_TIMEOUT,
929+
)
930+
except RequestException as error:
931+
raise RoboflowError(f"Video upload preparation request failed: {type(error).__name__}") from None
932+
if not response.ok:
933+
raise RoboflowError(response.text, status_code=response.status_code)
934+
return response.json()
935+
936+
937+
def put_video_upload(signed_url, video_path, required_headers, content_type) -> None:
938+
"""Stream original video bytes to the API-issued signed URL."""
939+
with open(video_path, "rb") as video:
940+
response = requests.put(
941+
signed_url,
942+
data=video,
943+
headers={"Content-Type": content_type, **required_headers},
944+
timeout=(30, 3600),
945+
)
946+
if not response.ok:
947+
raise RoboflowError(response.text, status_code=response.status_code)
948+
949+
950+
def get_video_upload_status(api_key, workspace_url, video_id, *, timeout=None) -> dict:
951+
"""Read processing state and the canonical Source ID after ingestion."""
952+
if timeout is None:
953+
timeout = VIDEO_UPLOAD_STATUS_TIMEOUT
954+
if timeout <= 0:
955+
raise ValueError("Video upload status timeout must be positive")
956+
try:
957+
response = requests.get(
958+
f"{API_URL}/{workspace_url}/upload/video/{video_id}",
959+
params={"api_key": api_key},
960+
timeout=timeout,
961+
)
962+
except RequestException as error:
963+
raise RoboflowError(f"Video upload status request failed for {video_id}: {type(error).__name__}") from None
964+
if not response.ok:
965+
raise RoboflowError(response.text, status_code=response.status_code)
966+
return response.json()
967+
968+
913969
# ---------------------------------------------------------------------------
914970
# Zip upload endpoints
915971
# ---------------------------------------------------------------------------

‎roboflow/core/project.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,89 @@ def __str__(self):
868868

869869
return json.dumps(json_str, indent=2)
870870

871+
def upload_video(
872+
self,
873+
video_path: str,
874+
*,
875+
batch_name: Optional[str] = None,
876+
tag_names: Optional[Union[str, List[str]]] = None,
877+
metadata: Optional[Dict] = None,
878+
split: Optional[str] = None,
879+
wait: bool = False,
880+
poll_interval: float = 2,
881+
poll_timeout: float = 300,
882+
) -> Dict:
883+
"""Upload original MP4/MOV bytes as a native video Source.
884+
885+
Returns the API processing status, including ``videoId``. Once the
886+
status is ``uploaded``, that ID is the canonical Source ID to annotate.
887+
The ID can change during ingestion if the video is deduplicated.
888+
``wait=False`` reads status once after the signed PUT; use
889+
:meth:`wait_for_video_upload` to continue polling later.
890+
"""
891+
if not os.path.isfile(video_path):
892+
raise ValueError(f"Video file not found: {video_path}")
893+
content_type = {".mp4": "video/mp4", ".mov": "video/quicktime"}.get(os.path.splitext(video_path)[1].lower())
894+
if content_type is None:
895+
raise ValueError("Native video upload accepts .mp4 and .mov files")
896+
897+
body: Dict = {"name": os.path.basename(video_path), "contentType": content_type}
898+
if batch_name is not None:
899+
body["batch"] = batch_name
900+
if tag_names is not None:
901+
body["tag"] = tag_names
902+
if metadata is not None:
903+
body["metadata"] = metadata
904+
if split is not None:
905+
body["split"] = split
906+
907+
prepared = rfapi.prepare_video_upload(self.__api_key, self.__workspace, self.__project_name, body)
908+
video_id = prepared["videoId"]
909+
if not prepared.get("signedUrl"):
910+
raise rfapi.RoboflowError("Video upload API did not return a signedUrl")
911+
rfapi.put_video_upload(prepared["signedUrl"], video_path, prepared.get("requiredHeaders", {}), content_type)
912+
if wait:
913+
return self.wait_for_video_upload(video_id, poll_interval=poll_interval, poll_timeout=poll_timeout)
914+
return self.get_video_upload_status(video_id)
915+
916+
def get_video_upload_status(self, video_id: str, *, timeout: Optional[float] = None) -> Dict:
917+
"""Get a native video's processing state and canonical Source ID.
918+
919+
``timeout`` limits connection and response-read inactivity. It is not
920+
a strict total request-duration cap.
921+
"""
922+
return rfapi.get_video_upload_status(self.__api_key, self.__workspace, video_id, timeout=timeout)
923+
924+
def wait_for_video_upload(self, video_id: str, *, poll_interval: float = 2, poll_timeout: float = 300) -> Dict:
925+
"""Poll until uploaded or failed, limiting each status read to the remaining budget.
926+
927+
With ``poll_timeout=0``, perform one status read using the default
928+
transport timeout and return a terminal result if it is already ready.
929+
Requests' timeouts measure connection/read inactivity, so this is not
930+
a strict wall-clock cap on a slowly streaming response.
931+
"""
932+
if poll_interval <= 0 or poll_timeout < 0:
933+
raise ValueError("poll_interval must be positive and poll_timeout must be nonnegative")
934+
deadline = time.monotonic() + poll_timeout
935+
while True:
936+
remaining = deadline - time.monotonic()
937+
if poll_timeout > 0 and remaining <= 0:
938+
raise rfapi.RoboflowError(
939+
f"Video upload {video_id} did not finish within the {poll_timeout}s polling budget; "
940+
"call get_video_upload_status to check later"
941+
)
942+
request_timeout = min(rfapi.VIDEO_UPLOAD_STATUS_TIMEOUT, remaining) if poll_timeout > 0 else None
943+
status = self.get_video_upload_status(video_id, timeout=request_timeout)
944+
if status.get("status") in {"uploaded", "failed"}:
945+
return status
946+
remaining = deadline - time.monotonic()
947+
if remaining <= 0:
948+
raise rfapi.RoboflowError(
949+
f"Video upload {video_id} is still {status.get('status')} after {poll_timeout}s; "
950+
"call get_video_upload_status to check later"
951+
)
952+
time.sleep(min(poll_interval, remaining))
953+
871954
def image(self, image_id: str) -> Dict:
872955
"""
873956
Fetch the details of a specific image from the Roboflow API.

0 commit comments

Comments
 (0)