diff --git a/README.md b/README.md index be59222..00b7f95 100644 --- a/README.md +++ b/README.md @@ -610,6 +610,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 diff --git a/openapi.yaml b/openapi.yaml index 417b87b..c378110 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1915,6 +1915,14 @@ paths: aspect_ratio: type: string example: "16:9" + model_name: + type: string + description: Use `flux` for FLUX self-inference image generation. + example: "flux" + config: + type: object + description: Model configuration. For FLUX supports size, num_inference_steps, guidance_scale, negative_prompt, and seed. + additionalProperties: true callback_url: type: string example: "https://webhook.example.com/callback" @@ -1983,16 +1991,32 @@ 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: Use `omnivoice` for OmniVoice self-inference text-to-speech. + example: "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 callback_url: type: string example: "https://webhook.example.com/callback" @@ -2004,6 +2028,39 @@ paths: schema: $ref: '#/components/schemas/AsyncResponse' + /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 +2079,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 @@ -5602,4 +5677,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_search_v2_migration_warnings.py b/tests/test_search_v2_migration_warnings.py new file mode 100644 index 0000000..2af8290 --- /dev/null +++ b/tests/test_search_v2_migration_warnings.py @@ -0,0 +1,358 @@ +import pytest + +import videodb.search as search_module +from videodb.collection import Collection +from videodb.exceptions import SearchError +from videodb.video import Video + + +WARNING = { + "code": "search_v2_migration_dual_read", + "message": "search() now uses Search V2 and may also check older indexes during migration.", + "docs": [{"label": "Search V2 search", "url": "https://example.test/search"}], +} +ASK_WARNING = { + "code": "ask_requires_search_v2_index", + "message": "ask() uses Search V2 indexes only.", + "docs": [{"label": "Ask", "url": "https://example.test/ask"}], +} +SEMANTIC_WARNING = { + "code": "semantic_search_v2_migration_dual_read", + "message": "semantic_search() may also check older indexes during migration.", + "docs": [{"label": "Semantic search", "url": "https://example.test/semantic"}], +} + + +class FakeConnection: + def __init__(self): + self.calls = [] + + def post(self, path, data=None, **kwargs): + self.calls.append((path, data or {})) + if path == "compile": + return { + "stream_url": "https://stream.example.test/compiled.m3u8", + "player_url": "https://player.example.test/watch?v=compiled", + } + if path.endswith("/ask"): + return {"answer": "I do not have enough information to answer.", "sources": [], "warnings": [ASK_WARNING]} + if path.endswith("/semantic-search"): + return {"results": _results("semantic"), "warnings": [SEMANTIC_WARNING]} + if path.endswith("/query"): + return {"results": _results("query"), "warnings": [WARNING]} + if path.endswith("/aggregate"): + return {"results": [{"label": "object", "count": 2}], "warnings": [WARNING]} + if path.endswith("/search/v2"): + if (data or {}).get("mode") == "deepsearch": + return {"response_type": "deepsearch", "results": _results("deepsearch"), "warnings": [WARNING]} + return {"response_type": "shots", "results": _results("v2"), "warnings": [WARNING]} + if path.endswith("/search"): + return {"results": _results("legacy")} + raise AssertionError(f"unexpected path: {path}") + + +class FakeCollectionConnection(FakeConnection): + pass + + +def _results(text): + return [ + { + "collection_id": "c1", + "video_id": "v1", + "length": 30, + "title": "video", + "docs": [{"start": 1, "end": 3, "text": text, "score": 0.8}], + } + ] + + +@pytest.fixture(autouse=True) +def reset_warning_state(): + search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() + yield + search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() + + +def test_search_v2_server_warnings_are_emitted_and_exposed(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2") as emitted: + response = video.search("find cars", filter={"legacy_key": "legacy_value"}) + + assert len(emitted) == 1 + assert conn.calls[-1] == ( + "video/v1/search/v2", + {"query": "find cars", "filter": {"legacy_key": "legacy_value"}}, + ) + assert response.warnings == [WARNING] + assert response.results.warnings == [WARNING] + assert response.shots[0].text == "v2" + + +def test_deepsearch_server_warnings_are_emitted_and_exposed_on_search_response(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars", mode="deepsearch") + + assert conn.calls[-1] == ("video/v1/search/v2", {"query": "find cars", "mode": "deepsearch"}) + assert response.response_type == "deepsearch" + assert response.warnings == [WARNING] + assert response.results.warnings == [WARNING] + + +def test_ask_server_warnings_are_emitted_and_exposed_on_ask_response(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"ask\(\) uses Search V2 indexes only"): + response = video.ask("what happened?") + + assert conn.calls[-1] == ( + "video/v1/ask", + {"question": "what happened?", "top_k": 15, "mode": "default", "include_sources": False}, + ) + assert response.answer == "I do not have enough information to answer." + assert response.warnings == [ASK_WARNING] + + +def test_search_response_compile_delegates_for_shots(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars") + + assert response.compile() == "https://stream.example.test/compiled.m3u8" + assert conn.calls[-1] == ( + "compile", + [{"video_id": "v1", "collection_id": "c1", "shots": [(1, 3)]}], + ) + assert response.results.stream_url == "https://stream.example.test/compiled.m3u8" + + +def test_search_response_play_delegates_for_shots(monkeypatch): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + played = [] + + monkeypatch.setattr(search_module, "play_stream", lambda url: played.append(url) or f"played:{url}") + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars") + + assert response.play() == "played:https://stream.example.test/compiled.m3u8" + assert played == ["https://stream.example.test/compiled.m3u8"] + + +def test_search_response_embed_code_delegates_for_deepsearch(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars", mode="deepsearch") + + html = response.get_embed_code(width="640", height=360, title="Result", allow_fullscreen=False) + assert response.response_type == "deepsearch" + assert 'src="https://player.example.test/embed?v=compiled"' in html + assert 'width="640"' in html + assert 'height="360"' in html + assert 'title="Result"' in html + assert "allowfullscreen" not in html + + +def test_search_response_media_helpers_raise_for_non_shot_response(): + response = search_module.SearchResponse( + FakeConnection(), + response_type="aggregate", + results=[{"label": "car", "count": 2}], + ) + + with pytest.raises(SearchError, match="does not contain shot results"): + response.compile() + with pytest.raises(SearchError, match="does not contain shot results"): + response.play() + with pytest.raises(SearchError, match="does not contain shot results"): + response.get_embed_code() + + +def test_semantic_search_accepts_plural_selectors_emits_and_exposes_warnings(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"semantic_search\(\) may also check older indexes"): + response = video.semantic_search("find cars", index_names=["scene"], index_ids=["idx-v2"]) + + assert conn.calls[-1] == ( + "video/v1/semantic-search", + { + "query": "find cars", + "index_names": ["scene"], + "index_ids": ["idx-v2"], + "top_k": 10, + "score_threshold": None, + "filter": None, + "return_fields": None, + }, + ) + assert response.warnings == [SEMANTIC_WARNING] + assert response.shots[0].text == "semantic" + + +def test_semantic_search_rejects_singular_selectors_in_sdk_signature(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(TypeError, match="unexpected keyword argument 'index_name'"): + video.semantic_search("find cars", index_name="scene") + with pytest.raises(TypeError, match="unexpected keyword argument 'index_id'"): + video.semantic_search("find cars", index_id="idx-old") + assert conn.calls == [] + + +def test_query_supports_singular_index_name_emits_and_exposes_warnings(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.query(index_name="scene") + + assert conn.calls[-1] == ( + "video/v1/query", + { + "index_name": "scene", + "index_id": None, + "filter": None, + "limit": 100, + "return_fields": None, + "sort": None, + }, + ) + assert response.warnings == [WARNING] + assert response.shots[0].text == "query" + + +def test_query_supports_singular_index_id(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.query(index_id="idx-v2") + + assert conn.calls[-1][0] == "video/v1/query" + assert conn.calls[-1][1]["index_name"] is None + assert conn.calls[-1][1]["index_id"] == "idx-v2" + assert response.warnings == [WARNING] + + +def test_query_rejects_plural_selectors_in_sdk_signature(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(TypeError, match="unexpected keyword argument 'index_ids'"): + video.query(index_ids=["idx-v2"]) + assert conn.calls == [] + + +def test_aggregate_keeps_singular_index_name_emits_and_returns_raw_warning_payload(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.aggregate(index_name="scene") + + assert conn.calls[-1] == ( + "video/v1/aggregate", + { + "index_name": "scene", + "index_id": None, + "filter": None, + "group_by": None, + "metric": "count", + "limit": 100, + "sort": None, + }, + ) + assert response["warnings"] == [WARNING] + + +def test_aggregate_keeps_singular_index_id(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.aggregate(index_id="idx-v2") + + assert conn.calls[-1][0] == "video/v1/aggregate" + assert conn.calls[-1][1]["index_name"] is None + assert conn.calls[-1][1]["index_id"] == "idx-v2" + assert response["warnings"] == [WARNING] + + +def test_search_index_id_routes_video_to_legacy_with_python_warning_and_scene_index_id_mapping(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided") as emitted: + response = video.search("find cars", index_id="old-scene-index") + + assert len(emitted) == 1 + assert conn.calls[-1][0] == "video/v1/search" + assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" + assert response.warnings == [] + assert response.shots[0].text == "legacy" + + +def test_search_index_id_routes_collection_to_legacy_with_python_warning_and_scene_index_id_mapping(): + conn = FakeCollectionConnection() + collection = Collection(conn, id="c1") + + with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided"): + response = collection.search("find cars", index_id="old-scene-index") + + assert conn.calls[-1][0] == "collection/c1/search" + assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" + assert response.warnings == [] + + +def test_explicit_legacy_search_warns_once_and_uses_old_endpoint(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"legacy_search\(\) searches older spoken-word and scene indexes only") as emitted: + video.legacy_search("find cars") + video.legacy_search("find people") + + assert len(emitted) == 1 + assert [call[0] for call in conn.calls] == ["video/v1/search", "video/v1/search"] + + +def test_search_rejects_mixed_legacy_and_v2_params_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match="Cannot mix legacy search parameters with Search V2 parameters"): + video.search("find cars", index_id="old-scene-index", top_k=5) + assert conn.calls == [] + + +def test_search_rejects_index_selectors_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): + video.search("find cars", index_names=["scene"]) + with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): + video.search("find cars", index_ids=["idx-v2"]) + assert conn.calls == [] + + +def test_search_rejects_internal_deepsearch_config_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match=r"deepsearch_config is not a public search\(\) option"): + video.search("find cars", deepsearch_config={"internal": True}) + assert conn.calls == [] diff --git a/videodb/__about__.py b/videodb/__about__.py index b4566c7..7f103dd 100644 --- a/videodb/__about__.py +++ b/videodb/__about__.py @@ -2,7 +2,7 @@ -__version__ = "0.4.5" +__version__ = "0.5.0" __title__ = "videodb" __author__ = "videodb" __email__ = "contact@videodb.io" diff --git a/videodb/__init__.py b/videodb/__init__.py index 59d2a1b..2282292 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -8,6 +8,8 @@ from videodb._constants import ( VIDEO_DB_API, IndexType, + IndexCapability, + FieldGroup, SceneExtractionType, MediaType, SearchType, @@ -23,8 +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 @@ -42,6 +52,10 @@ __all__ = [ "connect", "CaptureSession", + "GenerationJob", + "Sandbox", + "SandboxModel", + "VoiceClone", "WebSocketConnection", "CaptureClient", "Channel", @@ -53,7 +67,14 @@ "AuthenticationError", "InvalidRequestError", "IndexType", + "IndexCapability", + "FieldGroup", "SearchError", + "SearchResult", + "SearchResponse", + "AskResponse", + "Understanding", + "UnderstandingAnalyzer", "play_stream", "build_iframe_embed_code", "MediaType", @@ -71,6 +92,8 @@ "ReframeMode", "SegmentationType", "RTStreamChannelType", + "SandboxTier", + "SandboxStatus", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index f7de578..b13a889 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -36,6 +36,23 @@ class IndexType: scene = "scene" +class IndexCapability: + """Retrieval capabilities an index can be built for (``use_for``).""" + + semantic = "semantic" + query = "query" + aggregate = "aggregate" + + +class FieldGroup: + """Field groups that map artifact fields to retrieval capabilities.""" + + semantic = "semantic" + filter = "filter" + aggregate = "aggregate" + sort = "sort" + + class SceneExtractionType: shot_based = "shot" time_based = "time" @@ -80,7 +97,14 @@ class ApiPath: upload_url = "upload_url" transcription = "transcription" index = "index" + indexes = "indexes" + records = "records" + understand = "understand" search = "search" + ask = "ask" + semantic_search = "semantic-search" + query = "query" + aggregate = "aggregate" compile = "compile" workflow = "workflow" timeline = "timeline" @@ -116,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" class MeetingStatus: @@ -135,6 +184,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..28f1d4d 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,165 @@ 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, + ) -> "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 + :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}, + ) + 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) -> List["Sandbox"]: + """List all sandboxes, optionally filtered by status. + + :param str status: Filter by sandbox status (optional) + :return: List of :class:`Sandbox ` objects + :rtype: list[:class:`videodb.sandbox.Sandbox`] + """ + params = {} + 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 +657,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 7f57fc3..c8e1ea7 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 +from typing import Optional, Union, List, Dict, Any, Literal, Tuple from videodb._upload import ( upload, + upload_bytes, ) from videodb._constants import ( ApiPath, @@ -14,13 +17,25 @@ 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 -from videodb.search import SearchFactory, SearchResult +from videodb.search import ( + AskResponse, + SearchFactory, + SearchResponse, + SearchResult, + warn_explicit_legacy_search_once, + warn_legacy_search_once, + warn_response_warnings_once, +) logger = logging.getLogger(__name__) +MAX_GENERATE_TEXT_PAYLOAD_SIZE = 250 * 1024 + class Collection: """Collection class to interact with the Collection. @@ -133,6 +148,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. @@ -274,25 +337,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 @@ -352,28 +441,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