From 6943d144faa088dacdc95daedda26606ce1e8f02 Mon Sep 17 00:00:00 2001 From: Lalit Gupta Date: Fri, 24 Jul 2026 16:20:13 +0530 Subject: [PATCH 1/7] Release Sandbox SDK 0.5.1 --- README.md | 33 ++++ openapi.yaml | 306 +++++++++++++++++++++++++++++- tests/test_sandbox_compute_sdk.py | 238 +++++++++++++++++++++++ tests/test_sandbox_sdk.py | 232 ++++++++++++++++++++++ videodb/__about__.py | 2 +- videodb/__init__.py | 12 ++ videodb/_constants.py | 27 +++ videodb/_upload.py | 48 +++-- videodb/_utils/_http_client.py | 61 ++++-- videodb/client.py | 189 ++++++++++++++++++ videodb/collection.py | 200 ++++++++++++++++--- videodb/editor.py | 24 +-- videodb/job.py | 111 +++++++++++ videodb/rtstream.py | 27 ++- videodb/sandbox.py | 129 +++++++++++++ videodb/sandbox_models.py | 23 +++ videodb/scene.py | 3 + videodb/video.py | 6 + videodb/voice_clone.py | 65 +++++++ 19 files changed, 1659 insertions(+), 77 deletions(-) create mode 100644 tests/test_sandbox_compute_sdk.py create mode 100644 tests/test_sandbox_sdk.py create mode 100644 videodb/job.py create mode 100644 videodb/sandbox.py create mode 100644 videodb/sandbox_models.py create mode 100644 videodb/voice_clone.py diff --git a/README.md b/README.md index be59222..da2541d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ VideoDB Python SDK provides programmatic access to VideoDB's serverless video in - [Capture Sessions (Desktop Recording)](#capture-sessions-desktop-recording) - [WebSocket Events](#websocket-events) - [Meeting Recording](#meeting-recording) + - [Sandbox Compute](#sandbox-compute) - [Generative Media](#generative-media) - [Video Dubbing and Translation](#video-dubbing-and-translation) - [Transcoding](#transcoding) @@ -569,6 +570,29 @@ if meeting.is_completed: meeting_info = video.get_meeting() ``` +### Sandbox Compute + +Create dedicated compute for supported open-weight models: + +```python +from videodb import SandboxModel, SandboxTier + +sandbox = conn.create_sandbox( + tier=SandboxTier.small, + name="my-sandbox", + models=[SandboxModel.RTDETR_V2_R50VD.value], +) +sandbox.wait_for_ready(timeout=1200, interval=5) + +# Retrieve or list existing sandboxes. +same_sandbox = conn.get_sandbox(sandbox.id) +active_sandboxes = conn.list_sandboxes(status="active") + +# Keep the sandbox active while submitting inference work, then stop it. +sandbox.stop(grace=True) +sandbox.wait_for_stop(timeout=300, interval=5) +``` + ### Generative Media Generate images, audio, and videos using AI: @@ -610,6 +634,9 @@ response = coll.generate_text( model_name="pro", # basic, pro, or ultra response_type="text" # text or json ) + +# Large prompts are uploaded automatically with a unique filename and +# sent as prompt_url instead of inline JSON to avoid request payload limits. ``` ### Video Dubbing and Translation @@ -767,6 +794,9 @@ except SearchError as e: - **CaptureSession**: Desktop capture session with export - **CaptureClient**: Native binary client for screen/audio recording - **WebSocketConnection**: Real-time event streaming +- **Sandbox**: Dedicated compute for supported open-weight models +- **GenerationJob**: Asynchronous image or audio generation job +- **VoiceClone**: Reusable cloned-voice reference ### Constants and Enums @@ -776,6 +806,9 @@ except SearchError as e: - `Segmenter`: `word`, `sentence`, `time` - `TranscodeMode`: `lightning`, `economy` - `MediaType`: `video`, `audio`, `image` +- `SandboxTier`: `small`, `medium` +- `SandboxStatus`: `provisioning`, `active`, `alert`, `stopping`, `stopped`, `failed` +- `SandboxModel`: Supported Sandbox Compute model identifiers For detailed API documentation, visit [docs.videodb.io](https://docs.videodb.io). diff --git a/openapi.yaml b/openapi.yaml index 417b87b..e4ef418 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -73,6 +73,81 @@ components: type: string example: "https://api.videodb.io/async-response/job-123" + Sandbox: + type: object + properties: + sandbox_id: + type: string + example: "bx-123abc" + name: + type: string + example: "my-sandbox" + tier: + type: string + enum: [small, medium] + example: "small" + status: + type: string + enum: [provisioning, active, alert, stopping, stopped, failed] + example: "active" + models: + type: array + items: + type: string + example: ["rtdetr-v2-r50vd"] + model_categories: + type: array + items: + type: string + example: ["object_detection"] + region: + type: string + example: "aws-us-east-1" + expires_at: + type: string + format: date-time + created_at: + type: string + format: date-time + started_at: + type: string + format: date-time + stopped_at: + type: string + format: date-time + + SandboxResponse: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Sandbox' + + SandboxListResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + sandboxes: + type: array + items: + $ref: '#/components/schemas/Sandbox' + page: + type: integer + example: 1 + page_size: + type: integer + example: 20 + total: + type: integer + example: 1 + User: type: object properties: @@ -1915,6 +1990,18 @@ paths: aspect_ratio: type: string example: "16:9" + model_name: + type: string + description: Model used for image generation. + example: "black-forest-labs/FLUX.1-dev" + config: + type: object + description: Model configuration. For FLUX supports size, num_inference_steps, guidance_scale, negative_prompt, and seed. + additionalProperties: true + sandbox_id: + type: string + description: Sandbox ID used for self-hosted image generation. + example: "bx-123abc" callback_url: type: string example: "https://webhook.example.com/callback" @@ -1983,16 +2070,40 @@ paths: schema: type: object required: - - prompt - audio_type properties: prompt: type: string + description: Prompt for music or sound effect generation. example: "Generate upbeat background music" + text: + type: string + description: Text to convert to speech when `audio_type` is `voice`. + example: "Hello, welcome to VideoDB." audio_type: type: string - enum: [speech, sound_effect, music] - example: "music" + enum: [voice, sound_effect, music] + example: "voice" + voice_name: + type: string + description: Voice name for hosted text-to-speech. + example: "Default" + model_name: + type: string + description: Model used for text-to-speech. + example: "k2-fsa/OmniVoice" + config: + type: object + description: Voice configuration. For OmniVoice supports instructions, ref_audio, ref_text, response_format, speed, language, and max_new_tokens. + additionalProperties: true + sandbox_id: + type: string + description: Sandbox ID used for self-hosted text-to-speech. + example: "bx-123abc" + voice_clone_id: + type: string + description: Reusable VideoDB voice clone ID. + example: "vc-123abc" callback_url: type: string example: "https://webhook.example.com/callback" @@ -2004,6 +2115,167 @@ paths: schema: $ref: '#/components/schemas/AsyncResponse' + /sandbox: + post: + summary: Create a Sandbox Compute pool + tags: + - Sandbox + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tier: + type: string + enum: [small, medium] + example: "small" + name: + type: string + example: "my-sandbox" + callback_url: + type: string + format: uri + models: + type: array + items: + type: string + example: ["rtdetr-v2-r50vd"] + model_categories: + type: array + items: + type: string + example: ["object_detection"] + anyOf: + - required: [models] + - required: [model_categories] + responses: + '200': + description: Sandbox accepted for provisioning + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxResponse' + get: + summary: List Sandbox Compute pools + tags: + - Sandbox + security: + - ApiKeyAuth: [] + parameters: + - name: status + in: query + schema: + type: string + enum: [provisioning, active, alert, stopping, stopped, failed] + - name: page + in: query + schema: + type: integer + default: 1 + - name: page_size + in: query + schema: + type: integer + default: 20 + maximum: 100 + responses: + '200': + description: Sandbox list + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxListResponse' + + /sandbox/{sandbox_id}: + get: + summary: Get a Sandbox Compute pool + tags: + - Sandbox + security: + - ApiKeyAuth: [] + parameters: + - name: sandbox_id + in: path + required: true + schema: + type: string + example: "bx-123abc" + responses: + '200': + description: Current Sandbox state + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxResponse' + + /sandbox/{sandbox_id}/stop: + post: + summary: Stop a Sandbox Compute pool + tags: + - Sandbox + security: + - ApiKeyAuth: [] + parameters: + - name: sandbox_id + in: path + required: true + schema: + type: string + example: "bx-123abc" + requestBody: + content: + application/json: + schema: + type: object + properties: + grace: + type: boolean + default: true + responses: + '200': + description: Sandbox stopping + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxResponse' + + /job/{job_id}: + get: + summary: Get self-inference generation job status + security: + - ApiKeyAuth: [] + parameters: + - name: job_id + in: path + required: true + schema: + type: string + example: "550e8400-e29b-41d4-a716-446655440000" + responses: + '200': + description: Generation job status or final generated asset + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + status: + type: string + enum: [processing, done, failed] + data: + type: object + additionalProperties: true + message: + type: string + '404': + description: Job not found + /collection/{collection_id}/generate/text/: post: summary: Generate text using AI @@ -2022,27 +2294,45 @@ paths: application/json: schema: type: object - required: - - prompt + description: Provide either `prompt` for inline text or `prompt_url` for a previously uploaded prompt file. properties: prompt: type: string + description: Inline prompt text. Use for smaller prompts. example: "Summarize the content of this video" + prompt_url: + type: string + description: URL to a previously uploaded plain-text prompt file. Use for larger prompts. + example: "https://storage.googleapis.com/.../generate_text_prompt_123abc.txt" video_id: type: string example: "m-12345" model_name: type: string - example: "gpt-4" + description: Hosted tier (`mini`, `basic`, `pro`, `ultra`) or self-hosted sandbox model name. + example: "Qwen/Qwen3.5-27B" + sandbox_id: + type: string + description: Sandbox ID to use when `model_name` is a self-hosted model. + example: "bx-123abc" max_tokens: type: integer example: 500 temperature: type: number example: 0.7 + model_config: + type: object + description: Additional self-hosted model configuration. + additionalProperties: true callback_url: type: string example: "https://webhook.example.com/callback" + oneOf: + - required: + - prompt + - required: + - prompt_url responses: '200': description: Text generation started or completed @@ -5587,6 +5877,8 @@ tags: description: Content search and indexing - name: AI Generation description: AI-powered content generation + - name: Sandbox + description: Dedicated compute lifecycle for supported open-weight models - name: Billing description: Billing and usage management - name: RTStream @@ -5602,4 +5894,4 @@ tags: - name: Transcode description: Media transcoding operations - name: Assets - description: Cross-collection asset listing \ No newline at end of file + description: Cross-collection asset listing diff --git a/tests/test_sandbox_compute_sdk.py b/tests/test_sandbox_compute_sdk.py new file mode 100644 index 0000000..28302e8 --- /dev/null +++ b/tests/test_sandbox_compute_sdk.py @@ -0,0 +1,238 @@ +from collections import deque + +import pytest + +from videodb.audio import Audio +from videodb.client import Connection +from videodb.collection import Collection +from videodb.image import Image +from videodb.job import GenerationJob +from videodb.scene import Scene +from videodb.voice_clone import VoiceClone + + +class FakeConnection: + def __init__(self, *, posts=None, job_responses=None): + self.post_responses = deque(posts or []) + self.job_responses = deque(job_responses or []) + self.post_calls = [] + self.delete_calls = [] + + def post(self, path, data=None, **kwargs): + self.post_calls.append({"path": path, "data": data, "kwargs": kwargs}) + return self.post_responses.popleft() + + def delete(self, path, **kwargs): + self.delete_calls.append({"path": path, "kwargs": kwargs}) + + def get_job_status(self, job_id): + return self.job_responses.popleft() + + +def test_generate_image_returns_async_job_with_sandbox_payload(): + connection = FakeConnection( + posts=[ + { + "job_id": "job-image", + "status": "processing", + "job_type": "image", + } + ] + ) + collection = Collection(connection, id="collection-1") + + result = collection.generate_image( + prompt="A red fox", + model_name="black-forest-labs/FLUX.1-dev", + config={"size": "1024x1024"}, + sandbox_id="bx-123", + ) + + assert isinstance(result, GenerationJob) + assert result.job_id == "job-image" + assert result.result_type == "image" + assert connection.post_calls == [ + { + "path": "collection/collection-1/generate/image", + "data": { + "prompt": "A red fox", + "aspect_ratio": "1:1", + "callback_url": None, + "model_name": "black-forest-labs/FLUX.1-dev", + "config": {"size": "1024x1024"}, + "sandbox_id": "bx-123", + }, + "kwargs": {}, + } + ] + + +def test_generate_voice_returns_async_job_with_clone_and_sandbox_payload(): + connection = FakeConnection( + posts=[ + { + "job_id": "job-voice", + "status": "processing", + "job_type": "tts", + } + ] + ) + collection = Collection(connection, id="collection-1") + + result = collection.generate_voice( + text="Hello", + model_name="k2-fsa/OmniVoice", + sandbox_id="bx-123", + voice_clone_id="vc-123", + config={"language": "English"}, + ) + + assert isinstance(result, GenerationJob) + assert result.job_id == "job-voice" + assert result.result_type == "audio" + assert connection.post_calls[0]["data"] == { + "text": "Hello", + "audio_type": "voice", + "voice_name": "Default", + "model_name": "k2-fsa/OmniVoice", + "config": {"language": "English"}, + "callback_url": None, + "sandbox_id": "bx-123", + "voice_clone_id": "vc-123", + } + + +def test_generate_voice_rejects_conflicting_clone_aliases(): + collection = Collection(FakeConnection(), id="collection-1") + + with pytest.raises(ValueError, match="cannot both be different"): + collection.generate_voice( + text="Hello", + voice_clone_id="vc-one", + clone_voice_id="vc-two", + ) + + +def test_generate_text_forwards_sandbox_fields_and_wait_mode(): + connection = FakeConnection(posts=[{"job_id": "job-text"}]) + collection = Collection(connection, id="collection-1") + + result = collection.generate_text( + prompt="Summarize this", + model_name="Qwen/Qwen3.5-9B", + sandbox_id="bx-123", + max_tokens=128, + temperature=0.2, + model_config={"top_p": 0.9}, + wait=False, + ) + + assert result == {"job_id": "job-text"} + assert connection.post_calls == [ + { + "path": "collection/collection-1/generate/text", + "data": { + "prompt": "Summarize this", + "model_name": "Qwen/Qwen3.5-9B", + "response_type": "text", + "callback_url": None, + "sandbox_id": "bx-123", + "max_tokens": 128, + "temperature": 0.2, + "model_config": {"top_p": 0.9}, + }, + "kwargs": {"wait": False}, + } + ] + + +@pytest.mark.parametrize( + ("result_type", "asset_data", "asset_class"), + [ + ( + "audio", + {"id": "a-123", "collection_id": "collection-1"}, + Audio, + ), + ( + "image", + {"id": "img-123", "collection_id": "collection-1"}, + Image, + ), + ], +) +def test_generation_job_wait_returns_typed_asset( + result_type, + asset_data, + asset_class, +): + connection = FakeConnection( + job_responses=[ + { + "success": True, + "status": "done", + "data": asset_data, + } + ] + ) + job = GenerationJob(connection, "job-123", result_type=result_type) + + result = job.wait(timeout=10, interval=0) + + assert isinstance(result, asset_class) + assert result.id == asset_data["id"] + + +def test_scene_describe_forwards_sandbox_id(): + connection = FakeConnection(posts=[{"description": "A person enters."}]) + scene = Scene( + id="scene-1", + video_id="video-1", + start=0, + end=5, + description=None, + connection=connection, + ) + + result = scene.describe( + prompt="Describe the scene", + model_name="Qwen/Qwen3.5-9B", + model_config={"temperature": 0.1}, + sandbox_id="bx-123", + ) + + assert result == "A person enters." + assert connection.post_calls[0] == { + "path": "video/video-1/scene/scene-1/describe", + "data": { + "prompt": "Describe the scene", + "model_name": "Qwen/Qwen3.5-9B", + "model_config": {"temperature": 0.1}, + "sandbox_id": "bx-123", + }, + "kwargs": {}, + } + + +def test_voice_clone_create_and_delete_routes(): + connection = FakeConnection( + posts=[ + { + "voice_clone_id": "vc-123", + "ref_audio_id": "a-123", + "name": "Narrator", + } + ] + ) + + voice_clone = Connection.create_voice_clone( + connection, + ref_audio_id="a-123", + name="Narrator", + collection_id="collection-1", + ) + Connection.delete_voice_clone(connection, voice_clone.id) + + assert isinstance(voice_clone, VoiceClone) + assert voice_clone.id == "vc-123" + assert connection.delete_calls == [{"path": "voice_clone/vc-123", "kwargs": {}}] diff --git a/tests/test_sandbox_sdk.py b/tests/test_sandbox_sdk.py new file mode 100644 index 0000000..1f298dc --- /dev/null +++ b/tests/test_sandbox_sdk.py @@ -0,0 +1,232 @@ +from collections import deque + +import pytest + +from videodb import Sandbox, SandboxModel, SandboxStatus, SandboxTier +from videodb.client import Connection +from videodb.exceptions import InvalidRequestError + + +class FakeConnection: + def __init__(self, *, posts=None, gets=None): + self.post_responses = deque(posts or []) + self.get_responses = deque(gets or []) + self.post_calls = [] + self.get_calls = [] + + def post(self, path, data=None, **kwargs): + self.post_calls.append({"path": path, "data": data, "kwargs": kwargs}) + return self.post_responses.popleft() + + def get(self, path, **kwargs): + self.get_calls.append({"path": path, "kwargs": kwargs}) + return self.get_responses.popleft() + + +def test_create_sandbox_sends_model_selection_and_returns_resource(): + connection = FakeConnection( + posts=[ + { + "sandbox_id": "bx-123", + "tier": "small", + "status": "provisioning", + "name": "demo", + "models": ["rtdetr-v2-r50vd"], + "model_categories": ["object_detection"], + "region": "aws-ap-south-1", + "expires_at": "2026-07-25T10:00:00Z", + } + ] + ) + + sandbox = Connection.create_sandbox( + connection, + tier=SandboxTier.small, + name="demo", + callback_url="https://example.com/callback", + models=["rtdetr-v2-r50vd"], + model_categories=["object_detection"], + ) + + assert connection.post_calls == [ + { + "path": "sandbox", + "data": { + "tier": "small", + "name": "demo", + "callback_url": "https://example.com/callback", + "models": ["rtdetr-v2-r50vd"], + "model_categories": ["object_detection"], + }, + "kwargs": {}, + } + ] + assert isinstance(sandbox, Sandbox) + assert sandbox.id == "bx-123" + assert sandbox.models == ["rtdetr-v2-r50vd"] + assert sandbox.model_categories == ["object_detection"] + assert sandbox.region == "aws-ap-south-1" + assert sandbox.expires_at == "2026-07-25T10:00:00Z" + + +def test_get_and_list_sandboxes_preserve_server_fields(): + get_connection = FakeConnection( + gets=[ + { + "sandbox_id": "bx-123", + "tier": "small", + "status": "active", + "models": ["rtdetr-v2-r50vd"], + } + ] + ) + sandbox = Connection.get_sandbox(get_connection, "bx-123") + + list_connection = FakeConnection( + gets=[ + { + "sandboxes": [ + { + "sandbox_id": "bx-123", + "tier": "small", + "status": "active", + "models": ["rtdetr-v2-r50vd"], + } + ] + } + ] + ) + sandboxes = Connection.list_sandboxes( + list_connection, + status="active", + page=2, + page_size=10, + ) + + assert sandbox.id == "bx-123" + assert sandbox.is_active + assert list_connection.get_calls == [ + { + "path": "sandbox", + "kwargs": { + "params": { + "page": 2, + "page_size": 10, + "status": "active", + } + }, + } + ] + assert [item.id for item in sandboxes] == ["bx-123"] + assert sandboxes[0].models == ["rtdetr-v2-r50vd"] + + +def test_refresh_updates_sandbox_state(): + connection = FakeConnection( + gets=[ + { + "sandbox_id": "bx-123", + "status": "active", + "models": ["k2-fsa/OmniVoice"], + "model_categories": ["text_to_speech"], + } + ] + ) + sandbox = Sandbox(connection, sandbox_id="bx-123", status="provisioning") + + result = sandbox.refresh() + + assert result is sandbox + assert sandbox.status == SandboxStatus.active + assert sandbox.models == ["k2-fsa/OmniVoice"] + assert sandbox.model_categories == ["text_to_speech"] + + +def test_stop_uses_flattened_sdk_response_and_updates_state(): + connection = FakeConnection( + posts=[ + { + "sandbox_id": "bx-123", + "status": "stopping", + "models": ["rtdetr-v2-r50vd"], + } + ] + ) + sandbox = Sandbox( + connection, + sandbox_id="bx-123", + status="active", + models=["rtdetr-v2-r50vd"], + ) + + result = sandbox.stop(grace=False) + + assert result is sandbox + assert sandbox.status == SandboxStatus.stopping + assert sandbox.models == ["rtdetr-v2-r50vd"] + assert connection.post_calls == [ + { + "path": "sandbox/bx-123/stop", + "data": {"grace": False}, + "kwargs": {}, + } + ] + + +def test_wait_for_ready_returns_on_active_state(monkeypatch): + connection = FakeConnection( + gets=[ + {"sandbox_id": "bx-123", "status": "provisioning"}, + {"sandbox_id": "bx-123", "status": "active"}, + ] + ) + sandbox = Sandbox(connection, sandbox_id="bx-123", status="provisioning") + monkeypatch.setattr("videodb.sandbox.time.sleep", lambda _: None) + + result = sandbox.wait_for_ready(timeout=10, interval=0) + + assert result is sandbox + assert sandbox.status == SandboxStatus.active + + +def test_wait_for_ready_treats_alert_as_ready(): + connection = FakeConnection(gets=[{"sandbox_id": "bx-123", "status": "alert"}]) + sandbox = Sandbox(connection, sandbox_id="bx-123", status="provisioning") + + result = sandbox.wait_for_ready(timeout=10, interval=0) + + assert result is sandbox + assert sandbox.is_ready + + +def test_wait_for_ready_raises_on_terminal_state(): + connection = FakeConnection(gets=[{"sandbox_id": "bx-123", "status": "failed"}]) + sandbox = Sandbox(connection, sandbox_id="bx-123", status="provisioning") + + with pytest.raises(InvalidRequestError, match="entered terminal state"): + sandbox.wait_for_ready(timeout=10, interval=0) + + +def test_wait_for_stop_returns_on_stopped_state(monkeypatch): + connection = FakeConnection( + gets=[ + {"sandbox_id": "bx-123", "status": "stopping"}, + {"sandbox_id": "bx-123", "status": "stopped"}, + ] + ) + sandbox = Sandbox(connection, sandbox_id="bx-123", status="stopping") + monkeypatch.setattr("videodb.sandbox.time.sleep", lambda _: None) + + result = sandbox.wait_for_stop(timeout=10, interval=0) + + assert result is sandbox + assert sandbox.status == SandboxStatus.stopped + + +def test_public_sandbox_constants_and_models(): + assert SandboxTier.small == "small" + assert SandboxTier.medium == "medium" + assert SandboxStatus.active == "active" + assert SandboxModel.OMNIVOICE.value == "k2-fsa/OmniVoice" + assert SandboxModel.FLUX.value == "black-forest-labs/FLUX.1-dev" + assert SandboxModel.RTDETR_V2_R50VD.value == "rtdetr-v2-r50vd" diff --git a/videodb/__about__.py b/videodb/__about__.py index 7f103dd..85bca94 100644 --- a/videodb/__about__.py +++ b/videodb/__about__.py @@ -2,7 +2,7 @@ -__version__ = "0.5.0" +__version__ = "0.5.1" __title__ = "videodb" __author__ = "videodb" __email__ = "contact@videodb.io" diff --git a/videodb/__init__.py b/videodb/__init__.py index b736c88..2282292 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -25,10 +25,16 @@ ReframeMode, SegmentationType, RTStreamChannelType, + SandboxTier, + SandboxStatus, ) from videodb.client import Connection from videodb.search import AskResponse, SearchResponse, SearchResult from videodb.understanding import Understanding, UnderstandingAnalyzer +from videodb.job import GenerationJob +from videodb.sandbox import Sandbox +from videodb.sandbox_models import SandboxModel +from videodb.voice_clone import VoiceClone from videodb.capture_session import CaptureSession from videodb.websocket_client import WebSocketConnection from videodb.capture import CaptureClient, Channel, AudioChannel, VideoChannel, Channels, ChannelList @@ -46,6 +52,10 @@ __all__ = [ "connect", "CaptureSession", + "GenerationJob", + "Sandbox", + "SandboxModel", + "VoiceClone", "WebSocketConnection", "CaptureClient", "Channel", @@ -82,6 +92,8 @@ "ReframeMode", "SegmentationType", "RTStreamChannelType", + "SandboxTier", + "SandboxStatus", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index cdfcb5c..f7a0acc 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -140,11 +140,36 @@ class ApiPath: token = "token" websocket = "websocket" export = "export" + job = "job" + sandbox = "sandbox" + voice_clone = "voice_clone" + understand = "understand" + indexes = "indexes" + identities = "identities" + merge = "merge" + split = "split" + async_response = "async-response" + class Status: processing = "processing" in_progress = "in progress" + complete = "complete" + + +class SandboxTier: + small = "small" + medium = "medium" + + +class SandboxStatus: + provisioning = "provisioning" + active = "active" + stopping = "stopping" + stopped = "stopped" + failed = "failed" + alert = "alert" INDEX_TERMINAL_STATUSES = {"ready", "failed"} @@ -164,6 +189,8 @@ class HttpClientDefaultValues: timeout = 30 backoff_factor = 0.1 status_forcelist = [502, 503, 504] + max_poll_time = 500 + poll_interval = 5 class MaxSupported: diff --git a/videodb/_upload.py b/videodb/_upload.py index 0291abe..0a25e5d 100644 --- a/videodb/_upload.py +++ b/videodb/_upload.py @@ -1,9 +1,9 @@ -import requests - -from typing import Optional +import os +from typing import Optional, Union from urllib.parse import urlparse + +import requests from requests import HTTPError -import os from videodb._constants import ( @@ -20,6 +20,31 @@ def _is_url(path: str) -> bool: return all([parsed.scheme in ("http", "https"), parsed.netloc]) +def upload_bytes( + _connection, + content: Union[str, bytes], + name: str, + content_type: str = "application/octet-stream", + collection_id: Optional[str] = None, +) -> str: + """Upload in-memory content using a presigned upload URL and return the object URL.""" + collection_id = collection_id or _connection.collection_id + upload_url_data = _connection.get( + path=f"{ApiPath.collection}/{collection_id}/{ApiPath.upload_url}", + params={"name": name}, + ) + upload_url = upload_url_data.get("upload_url") + + try: + files = {"file": (name, content, content_type)} + response = requests.post(upload_url, files=files) + response.raise_for_status() + except requests.exceptions.RequestException as e: + raise VideodbError("Error while uploading content", cause=e) + + return upload_url + + def upload( _connection, source: Optional[str] = None, @@ -71,16 +96,13 @@ def upload( if file_path: try: name = os.path.splitext(os.path.basename(file_path))[0] if not name else name - upload_url_data = _connection.get( - path=f"{ApiPath.collection}/{collection_id}/{ApiPath.upload_url}", - params={"name": name}, - ) - upload_url = upload_url_data.get("upload_url") with open(file_path, "rb") as file: - files = {"file": (name, file)} - response = requests.post(upload_url, files=files) - response.raise_for_status() - url = upload_url + url = upload_bytes( + _connection=_connection, + content=file, + name=name, + collection_id=collection_id, + ) except FileNotFoundError as e: raise VideodbError("File not found", cause=e) diff --git a/videodb/_utils/_http_client.py b/videodb/_utils/_http_client.py index ab571f6..6507e38 100644 --- a/videodb/_utils/_http_client.py +++ b/videodb/_utils/_http_client.py @@ -1,8 +1,8 @@ """Http client Module.""" import logging +import time import requests -import backoff from tqdm import tqdm from typing import ( @@ -65,6 +65,8 @@ def __init__( self.base_url = base_url self.show_progress = False self.progress_bar = None + self.max_poll_time = HttpClientDefaultValues.max_poll_time + self.poll_interval = HttpClientDefaultValues.poll_interval logger.debug(f"Initialized http client with base url: {self.base_url}") def _make_request( @@ -73,6 +75,7 @@ def _make_request( path: str, base_url: Optional[str] = None, headers: Optional[dict] = None, + wait: bool = True, **kwargs, ): """Make a request to the api @@ -81,6 +84,7 @@ def _make_request( :param str path: The path to make the request to :param str base_url: (optional) The base url to use for the request :param dict headers: (optional) The headers to use for the request + :param bool wait: If False, return raw data without polling (default True) :param kwargs: The keyword arguments to pass to the request method :return: json response from the request """ @@ -90,6 +94,11 @@ def _make_request( request_headers = {**self.session.headers, **(headers or {})} response = method(url, headers=request_headers, timeout=timeout, **kwargs) response.raise_for_status() + if not wait: + data = response.json().get("data") + if data is not None: + return data + return response.json() return self._parse_response(response) except requests.exceptions.RequestException as e: @@ -133,23 +142,31 @@ def _handle_request_error(self, e: requests.exceptions.RequestException) -> None f"Invalid request: {str(e)}", e.response ) from None - @backoff.on_exception( - backoff.constant, Exception, max_time=500, interval=5, logger=None, jitter=None - ) def _get_output(self, url: str): - """Get the output from an async request""" - response_json = self.session.get(url).json() - if ( - response_json.get("status") == Status.in_progress - or response_json.get("status") == Status.processing - ): + """Poll an output URL until the job completes or times out.""" + start = time.monotonic() + while True: + response_json = self.session.get(url).json() + status = response_json.get("status") + if status not in (Status.in_progress, Status.processing): + break + percentage = response_json.get("data", {}).get("percentage") if percentage and self.show_progress and self.progress_bar: self.progress_bar.n = int(percentage) self.progress_bar.update(0) - logger.debug("Waiting for processing to complete") - raise Exception("Stuck on processing status") from None + elapsed = time.monotonic() - start + if elapsed >= self.max_poll_time: + raise RequestTimeoutError( + f"Polling timed out after {int(elapsed)}s " + f"(max_poll_time={self.max_poll_time})", + None, + ) + + logger.debug("Waiting for processing to complete (%.0fs elapsed)", elapsed) + time.sleep(self.poll_interval) + if self.show_progress and self.progress_bar: self.progress_bar.n = 100 self.progress_bar.update(0) @@ -209,19 +226,31 @@ def _format_headers(self, headers: dict): formatted_headers[f"x-{key}"] = value return formatted_headers + def _apply_poll_overrides(self, kwargs): + """Extract and apply per-call poll overrides from kwargs.""" + self.max_poll_time = kwargs.pop( + "max_poll_time", HttpClientDefaultValues.max_poll_time + ) + self.poll_interval = kwargs.pop( + "poll_interval", HttpClientDefaultValues.poll_interval + ) + def get( - self, path: str, show_progress: Optional[bool] = False, **kwargs + self, path: str, show_progress: Optional[bool] = False, wait: bool = True, **kwargs ) -> requests.Response: """Make a get request""" self.show_progress = show_progress - return self._make_request(method=self.session.get, path=path, **kwargs) + self._apply_poll_overrides(kwargs) + return self._make_request(method=self.session.get, path=path, wait=wait, **kwargs) def post( - self, path: str, data=None, show_progress: Optional[bool] = False, **kwargs + self, path: str, data=None, show_progress: Optional[bool] = False, + wait: bool = True, **kwargs, ) -> requests.Response: """Make a post request""" self.show_progress = show_progress - return self._make_request(self.session.post, path, json=data, **kwargs) + self._apply_poll_overrides(kwargs) + return self._make_request(self.session.post, path, json=data, wait=wait, **kwargs) def put(self, path: str, data=None, **kwargs) -> requests.Response: """Make a put request""" diff --git a/videodb/client.py b/videodb/client.py index 7eac7bb..e53374d 100644 --- a/videodb/client.py +++ b/videodb/client.py @@ -1,4 +1,5 @@ import logging +import requests from typing import ( Optional, @@ -11,6 +12,7 @@ TranscodeMode, VideoConfig, AudioConfig, + HttpClientDefaultValues, ) from videodb.collection import Collection @@ -19,6 +21,8 @@ from videodb.audio import Audio from videodb.image import Image from videodb.meeting import Meeting +from videodb.sandbox import Sandbox +from videodb.voice_clone import VoiceClone from videodb.capture_session import CaptureSession from videodb.websocket_client import WebSocketConnection @@ -264,6 +268,182 @@ def get_transcode_details(self, job_id: str) -> dict: """ return self.get(path=f"{ApiPath.transcode}/{job_id}") + def get_job_status(self, job_id: str) -> dict: + """Get status/details for a self-inference generation job. + + This preserves and normalizes the job response wrapper instead of using + the standard SDK response parser, because callers need the top-level + job status while polling. + + :param str job_id: ID of the generation job + :return: Normalized job response with success, status, data, message + :rtype: dict + """ + try: + url = f"{self.base_url}/{ApiPath.job}/{job_id}" + response = self.session.get(url, timeout=HttpClientDefaultValues.timeout) + response.raise_for_status() + response_json = response.json() + except requests.exceptions.RequestException as e: + self._handle_request_error(e) + except ValueError: + from videodb.exceptions import InvalidRequestError + + raise InvalidRequestError("Invalid request: Unable to parse job response") from None + + data = response_json.get("data") or {} + status = response_json.get("status") or data.get("status") + success = response_json.get("success", False) + + if not status: + status = "done" if success else "failed" + + return { + "success": success, + "status": status, + "data": data, + "message": response_json.get("message"), + } + + def wait_for_job( + self, + job_id: str, + timeout: int = 600, + interval: int = 5, + result_type: str = None, + ): + """Poll a self-inference generation job until completion. + + :param str job_id: ID of the generation job + :param int timeout: Maximum seconds to wait + :param int interval: Seconds between polls + :param str result_type: Optional expected result type, "audio" or "image" + :return: Generated SDK asset or final job data + """ + from videodb.job import GenerationJob + + return GenerationJob( + self, job_id=job_id, result_type=result_type + ).wait(timeout=timeout, interval=interval) + + def create_sandbox( + self, + tier: Optional[str] = None, + name: Optional[str] = None, + callback_url: Optional[str] = None, + model_categories: Optional[List[str]] = None, + models: Optional[List[str]] = None, + ) -> "Sandbox": + """Create a new sandbox (GPU compute pool). + + :param str tier: Sandbox tier — "small" or "medium" (default: server decides) + :param str name: Human-readable name (auto-generated if not provided) + :param str callback_url: URL to receive sandbox lifecycle webhooks + :param list[str] model_categories: Model categories to prepare for this sandbox, e.g. ``["vlm", "image_generation"]`` (optional) + :param list[str] models: Specific model names to prepare for this sandbox (optional) + :return: :class:`Sandbox ` object in provisioning state + :rtype: :class:`videodb.sandbox.Sandbox` + """ + data = self.post( + path=ApiPath.sandbox, + data={ + "tier": tier, + "name": name, + "callback_url": callback_url, + "model_categories": model_categories, + "models": models, + }, + ) + return Sandbox(self, **(data or {})) + + def get_sandbox(self, sandbox_id: str) -> "Sandbox": + """Get a sandbox by ID. + + :param str sandbox_id: The sandbox ID + :return: :class:`Sandbox ` object + :rtype: :class:`videodb.sandbox.Sandbox` + """ + data = self.get(path=f"{ApiPath.sandbox}/{sandbox_id}") + return Sandbox(self, **(data or {})) + + def list_sandboxes( + self, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20, + ) -> List["Sandbox"]: + """List all sandboxes, optionally filtered by status. + + :param str status: Filter by sandbox status (optional) + :param int page: Page number (default: 1) + :param int page_size: Sandboxes per page (default: 20, maximum: 100) + :return: List of :class:`Sandbox ` objects + :rtype: list[:class:`videodb.sandbox.Sandbox`] + """ + params = {"page": page, "page_size": page_size} + if status: + params["status"] = status + data = self.get(path=ApiPath.sandbox, params=params) + sandboxes_data = (data or {}).get("sandboxes", []) + return [Sandbox(self, **s) for s in sandboxes_data] + + def create_voice_clone( + self, + ref_audio_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + ref_text: Optional[str] = None, + language: Optional[str] = None, + collection_id: Optional[str] = None, + ) -> "VoiceClone": + """Create a reusable voice clone from an existing audio asset. + + :param str ref_audio_id: Source audio ID to use as the voice reference. + :param str name: Human-readable name (optional). + :param str description: Description (optional). + :param str ref_text: Text spoken in the reference audio (optional). + :param str language: Language code, e.g. ``"en"`` (optional). + :param str collection_id: Collection associated with the source audio (optional). + :return: :class:`VoiceClone ` object. + :rtype: :class:`videodb.voice_clone.VoiceClone` + """ + data = self.post( + path=ApiPath.voice_clone, + data={ + "ref_audio_id": ref_audio_id, + "name": name, + "description": description, + "ref_text": ref_text, + "language": language, + "collection_id": collection_id, + }, + ) + return VoiceClone(self, **(data or {})) + + def get_voice_clone(self, voice_clone_id: str) -> "VoiceClone": + """Get a voice clone by ID.""" + data = self.get(path=f"{ApiPath.voice_clone}/{voice_clone_id}") + return VoiceClone(self, **(data or {})) + + def list_voice_clones( + self, + page: int = 1, + page_size: int = 20, + language: Optional[str] = None, + ) -> List["VoiceClone"]: + """List voice clones for the current user.""" + params = {"page": page, "page_size": page_size} + if language: + params["language"] = language + data = self.get(path=ApiPath.voice_clone, params=params) + voice_clones = (data or {}).get("voice_clones", []) + return [VoiceClone(self, **v) for v in voice_clones] + + def delete_voice_clone(self, voice_clone_id: str) -> None: + """Delete a voice clone by ID.""" + self.delete(path=f"{ApiPath.voice_clone}/{voice_clone_id}") + return None + def upload( self, source: Optional[str] = None, @@ -494,3 +674,12 @@ def generate_client_token(self, expires_in: int = 86400) -> str: data={"expires_in": expires_in}, ) return response.get("token") + + def get_async_response(self, id: str) -> dict: + """Get the details of an async response. + + :param str id: ID of the async response + :return: Details of the async response + :rtype: dict + """ + return self.get(path=f"{ApiPath.async_response}/{id}", wait=False) diff --git a/videodb/collection.py b/videodb/collection.py index 6414080..491d966 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -1,8 +1,11 @@ +import json import logging +import uuid from typing import Optional, Union, List, Dict, Any, Literal, Tuple from videodb._upload import ( upload, + upload_bytes, ) from videodb._constants import ( ApiPath, @@ -14,6 +17,8 @@ from videodb.video import Video from videodb.audio import Audio from videodb.image import Image +from videodb.job import GenerationJob +from videodb.voice_clone import VoiceClone from videodb.meeting import Meeting from videodb.capture_session import CaptureSession from videodb.rtstream import RTStream, RTStreamSearchResult, RTStreamShot @@ -27,6 +32,8 @@ logger = logging.getLogger(__name__) +MAX_GENERATE_TEXT_PAYLOAD_SIZE = 250 * 1024 + class Collection: """Collection class to interact with the Collection. @@ -139,6 +146,54 @@ def delete_audio(self, audio_id: str) -> None: path=f"{ApiPath.audio}/{audio_id}", params={"collection_id": self.id} ) + def create_voice_clone( + self, + ref_audio_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + ref_text: Optional[str] = None, + language: Optional[str] = None, + ) -> VoiceClone: + """Create a reusable voice clone from an audio in this collection. + + :param str ref_audio_id: Source audio ID to use as the voice reference. + :param str name: Human-readable name (optional). + :param str description: Description (optional). + :param str ref_text: Text spoken in the reference audio (optional). + :param str language: Language code, e.g. ``"en"`` (optional). + :return: :class:`VoiceClone ` object. + :rtype: :class:`videodb.voice_clone.VoiceClone` + """ + return self._connection.create_voice_clone( + ref_audio_id=ref_audio_id, + name=name, + description=description, + ref_text=ref_text, + language=language, + collection_id=self.id, + ) + + def get_voice_clone(self, voice_clone_id: str) -> VoiceClone: + """Get a voice clone by ID.""" + return self._connection.get_voice_clone(voice_clone_id) + + def list_voice_clones( + self, + page: int = 1, + page_size: int = 20, + language: Optional[str] = None, + ) -> List[VoiceClone]: + """List user voice clones, optionally filtered by language.""" + return self._connection.list_voice_clones( + page=page, + page_size=page_size, + language=language, + ) + + def delete_voice_clone(self, voice_clone_id: str) -> None: + """Delete a voice clone by ID.""" + return self._connection.delete_voice_clone(voice_clone_id) + def get_images(self) -> List[Image]: """Get all the images in the collection. @@ -280,25 +335,51 @@ def generate_image( prompt: str, aspect_ratio: Optional[Literal["1:1", "9:16", "16:9", "4:3", "3:4"]] = "1:1", callback_url: Optional[str] = None, - ) -> Image: + model_name: Optional[str] = None, + config: Optional[dict] = None, + sandbox_id: Optional[str] = None, + wait: bool = False, + poll_interval: int = 5, + timeout: int = 600, + ) -> Union[Image, GenerationJob]: """Generate an image from a prompt. :param str prompt: Prompt for the image generation - :param str aspect_ratio: Aspect ratio of the image (optional) + :param str aspect_ratio: Aspect ratio of the image (optional, hosted models) :param str callback_url: URL to receive the callback (optional) - :return: :class:`Image ` object - :rtype: :class:`videodb.image.Image` + :param str model_name: Model name. Use ``"black-forest-labs/FLUX.1-dev"`` for FLUX self-inference. + :param dict config: Model configuration. Used by FLUX. + :param str sandbox_id: ID of the sandbox to route the self-inference job to (optional). + :param bool wait: If True, wait for self-inference jobs and return Image. + :param int poll_interval: Seconds between job polls when wait=True. + :param int timeout: Maximum seconds to wait when wait=True. + :return: :class:`Image ` or :class:`GenerationJob ` + :rtype: Union[:class:`videodb.image.Image`, :class:`videodb.job.GenerationJob`] """ + payload = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "callback_url": callback_url, + } + if model_name: + payload["model_name"] = model_name + if config is not None: + payload["config"] = config + if sandbox_id: + payload["sandbox_id"] = sandbox_id + image_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.image}", - data={ - "prompt": prompt, - "aspect_ratio": aspect_ratio, - "callback_url": callback_url, - }, + data=payload, ) - if image_data: - return Image(self._connection, **image_data) + if not image_data: + return None + if image_data.get("job_id"): + job = GenerationJob.from_data( + self._connection, image_data, result_type="image" + ) + return job.wait(timeout=timeout, interval=poll_interval) if wait else job + return Image(self._connection, **image_data) def generate_music( self, prompt: str, duration: int = 5, callback_url: Optional[str] = None @@ -358,28 +439,55 @@ def generate_voice( voice_name: str = "Default", config: dict = {}, callback_url: Optional[str] = None, - ) -> Audio: + model_name: str = "elevenlabs", + sandbox_id: Optional[str] = None, + voice_clone_id: Optional[str] = None, + clone_voice_id: Optional[str] = None, + wait: bool = False, + poll_interval: int = 5, + timeout: int = 600, + ) -> Union[Audio, GenerationJob]: """Generate voice from text. :param str text: Text to convert to voice :param str voice_name: Name of the voice to use :param dict config: Configuration for the voice generation :param str callback_url: URL to receive the callback (optional) - :return: :class:`Audio