diff --git a/README.md b/README.md index be59222..d10f4a2 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ VideoDB Python SDK provides programmatic access to VideoDB's serverless video in - [Uploading Media](#uploading-media) - [Updating Video Metadata](#updating-video-metadata) - [Viewing and Streaming Videos](#viewing-and-streaming-videos) - - [Searching Inside Videos](#searching-inside-videos) + - [Understanding Videos](#understanding-videos) + - [Creating Indexes](#creating-indexes) + - [Retrieving Indexed Content](#retrieving-indexed-content) - [Working with Transcripts](#working-with-transcripts) - - [Scene Extraction and Indexing](#scene-extraction-and-indexing) + - [Legacy Scene Extraction and Indexing](#legacy-scene-extraction-and-indexing) - [Adding Subtitles](#adding-subtitles) - [Generating Thumbnails](#generating-thumbnails) - [Working with Collections](#working-with-collections) @@ -59,6 +61,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) @@ -145,35 +148,132 @@ videodb.play_stream(stream_url) video.play() ``` -### Searching Inside Videos +### Understanding Videos -Index and search video content semantically: +VideoDB 0.5 separates the retrieval pipeline into three primitives: **Understand → Index → Retrieve**. Understanding runs analyzers once and stores reusable, timestamped artifacts; indexing then decides how those artifacts can be retrieved. ```python -from videodb import SearchType, IndexType +understanding = video.understand( + analyzers=[ + {"type": "spoken_words", "name": "transcript"}, + {"type": "vlm", "name": "scene"}, + ] +) +understanding.wait_until_complete() + +# Inspect analyzer status or output +for analyzer in understanding.list_analyzers(): + print(analyzer.name, analyzer.type, analyzer.status) + +scene = understanding.get_analyzer("scene") +scene_output = scene.get_output() +``` + +Built-in analyzer types include `spoken_words`, `vlm`, `object_detection`, `ocr`, `brand_detection`, `activity_recognition`, and `location_detection`. An understanding run can be reopened with `video.get_understanding(id)`, listed with `video.list_understandings()`, or deleted independently of the video. + +### Creating Indexes + +Create one or more retrieval-ready indexes from the stored analyzer artifacts without analyzing the video again: + +```python +from videodb import IndexCapability + +transcript = understanding.get_analyzer("transcript") +scene = understanding.get_analyzer("scene") + +transcript_index = video.index( + source=transcript, + name="transcript", + use_for=[IndexCapability.semantic, IndexCapability.query], +) +scene_index = video.index( + source=scene, + name="scene", + use_for=[ + IndexCapability.semantic, + IndexCapability.query, + IndexCapability.aggregate, + ], +) + +scene_index.wait_until_complete() +print(scene_index.status, scene_index.fields, scene_index.field_schema) +``` + +`use_for` declares whether an index supports semantic search, structured queries, and aggregation. The optional `fields` argument maps artifact fields into `semantic`, `filter`, `aggregate`, and `sort` groups; when omitted, VideoDB derives sensible groups from the artifact. + +You can also index your own timestamped records: + +```python +from videodb import FieldGroup + +chapters = video.index( + name="chapters", + source=[ + {"start": 0.0, "end": 12.4, "summary": "Opening city skyline", "kind": "intro"}, + {"start": 12.4, "end": 45.0, "summary": "CEO discusses Q4 results", "kind": "presentation"}, + ], + use_for=[IndexCapability.semantic, IndexCapability.query, IndexCapability.aggregate], + fields={ + FieldGroup.semantic: ["summary"], + FieldGroup.filter: ["kind"], + FieldGroup.aggregate: ["kind"], + }, +) +``` -# Index spoken words for semantic search -video.index_spoken_words() +Manage and inspect indexes through their manifests: -# Search for content -results = video.search("morning sunlight") +```python +indexes = video.list_indexes() +same_index = video.get_index(index_id=chapters.index_id) +page = same_index.records(limit=20) + +same_index.delete() # Deletes the index, not its video or understanding artifact +``` -# Access search results -shots = results.get_shots() -for shot in shots: - print(f"Found at {shot.start}s - {shot.end}s: {shot.text}") +### Retrieving Indexed Content -# Sort results by timestamp instead of relevance score -results = coll.search(query="morning sunlight", sort_docs_on="start") +Use high-level `search()` when VideoDB should plan across the available indexes, or choose a direct retrieval primitive when your application knows the operation: + +```python +# Natural-language retrieval; VideoDB selects and combines indexes +response = video.search( + query="someone discussing a product while holding a phone", + top_k=10, +) +for shot in response: + print(shot.start, shot.end, shot.generate_stream()) + +# Direct vector retrieval over selected semantic indexes +results = video.semantic_search( + query="a presentation about financial results", + index_names=["scene", "transcript"], + top_k=10, +) + +# Exact structured filtering over one index +results = video.query( + index_name="chapters", + filter=[{"field": "kind", "op": "==", "value": "presentation"}], + limit=20, +) + +# Counts and facets over one index +counts = video.aggregate( + index_name="chapters", + group_by="kind", + metric="count", +) -# Play compiled results -results.play() +# A grounded answer with optional timestamped sources +answer = video.ask( + question="What financial results were discussed?", + include_sources=True, +) ``` -**Search Types:** -- `SearchType.semantic` - Semantic search (default) -- `SearchType.keyword` - Keyword-based search -- `SearchType.scene` - Visual scene search +The same `search()`, `semantic_search()`, `query()`, `aggregate()`, and `ask()` methods are available on collections for retrieval across multiple videos. Existing applications can continue using `index_spoken_words()`, `index_scenes()`, and `legacy_search()` for legacy indexes. ### Working with Transcripts @@ -205,9 +305,9 @@ translated = video.translate_transcript( - `videodb.Segmenter.sentence` - Sentence-level timestamps - `videodb.Segmenter.time` - Time-based segments -### Scene Extraction and Indexing +### Legacy Scene Extraction and Indexing -Extract and analyze scenes from videos: +Extract and analyze scenes with the legacy indexing API. New applications should prefer `video.understand(...)` followed by `video.index(...)` as shown above: ```python from videodb import SceneExtractionType @@ -569,6 +669,29 @@ if meeting.is_completed: meeting_info = video.get_meeting() ``` +### Sandbox Compute + +Create dedicated compute for supported open-weight models: + +```python +from videodb import SandboxTier + +sandbox = conn.create_sandbox( + tier=SandboxTier.small, + name="my-sandbox", + models=["rtdetr-v2-r50vd"], +) +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 +733,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 @@ -760,6 +886,10 @@ except SearchError as e: - **Timeline**: Multi-track video editor - **SearchResult**: Search results with shots - **Shot**: Time-segmented video clip +- **Understanding**: A reusable video analysis run containing analyzer artifacts +- **UnderstandingAnalyzer**: Status, output, and index-source handle for one analyzer +- **Index**: Retrieval-ready index manifest with status, capabilities, and schema +- **IndexRecord**: One timestamped record stored in an index - **Scene**: Visual scene with frames - **SceneCollection**: Collection of extracted scenes - **Meeting**: Meeting recording session @@ -767,15 +897,22 @@ 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 -- `IndexType`: `spoken_word`, `scene` -- `SearchType`: `semantic`, `keyword`, `scene` -- `SceneExtractionType`: `shot_based`, `time_based` +- `IndexCapability`: `semantic`, `query`, `aggregate` +- `FieldGroup`: `semantic`, `filter`, `aggregate`, `sort` +- `IndexType`: `spoken_word`, `scene` (legacy) +- `SearchType`: `semantic`, `keyword` (legacy) +- `SceneExtractionType`: `shot_based`, `time_based` (legacy) - `Segmenter`: `word`, `sentence`, `time` - `TranscodeMode`: `lightning`, `economy` - `MediaType`: `video`, `audio`, `image` +- `SandboxTier`: `small`, `medium` +- `SandboxStatus`: `provisioning`, `active`, `alert`, `stopping`, `stopped`, `failed` 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/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..79293de 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -25,10 +25,15 @@ 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.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 +51,9 @@ __all__ = [ "connect", "CaptureSession", + "GenerationJob", + "Sandbox", + "VoiceClone", "WebSocketConnection", "CaptureClient", "Channel", @@ -82,6 +90,8 @@ "ReframeMode", "SegmentationType", "RTStreamChannelType", + "SandboxTier", + "SandboxStatus", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index cdfcb5c..1a1cde8 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,10 +189,14 @@ class HttpClientDefaultValues: timeout = 30 backoff_factor = 0.1 status_forcelist = [502, 503, 504] + max_poll_time = 500 + poll_interval = 5 class MaxSupported: fade_duration = 5 + # Prompts larger than this are uploaded as files instead of sent inline + generate_text_payload_size = 250 * 1024 class SubtitleBorderStyle: 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..973cb8b 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,33 @@ 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, timeout=HttpClientDefaultValues.timeout + ).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 +228,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..a36966f 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -1,12 +1,16 @@ +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, IndexType, + MaxSupported, MediaType, SearchType, _InternalSearchType, @@ -14,6 +18,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 @@ -139,6 +145,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 +334,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 +438,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