diff --git a/requirements-dev.txt b/requirements-dev.txt index bf8db06..07fee5c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ ruff==0.1.7 pytest==7.4.3 -twine==4.0.2 +twine==5.1.1 wheel==0.42.0 diff --git a/setup.py b/setup.py index bdb8b05..db2f955 100644 --- a/setup.py +++ b/setup.py @@ -2,16 +2,16 @@ import os from setuptools import setup, find_packages -ROOT = os.path.dirname(__file__) +ROOT = os.path.dirname(os.path.abspath(__file__)) # Read in the package version per recommendations from: # https://packaging.python.org/guides/single-sourcing-package-version/ -def get_version(): - with open(os.path.join(ROOT, "videodb", "__init__.py")) as f: - for line in f.readlines(): - if line.startswith("__version__"): - return line.split("=")[1].strip().strip('''"''') + +about_path = os.path.join(ROOT, "videodb", "__about__.py") +about = {} +with open(about_path) as fp: + exec(fp.read(), about) # read the contents of README file @@ -19,14 +19,15 @@ def get_version(): setup( - name="videodb", - version=get_version(), - author="videodb", - author_email="contact@videodb.io", + name=about["__title__"], + version=about["__version__"], + author=about["__author__"], + author_email=about["__email__"], + license=about["__license__"], description="VideoDB Python SDK", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/video-db/videodb-python", + url=about["__url__"], packages=find_packages(exclude=["tests", "tests.*"]), python_requires=">=3.8", install_requires=[ diff --git a/videodb/__about__.py b/videodb/__about__.py new file mode 100644 index 0000000..aa144bc --- /dev/null +++ b/videodb/__about__.py @@ -0,0 +1,9 @@ +""" About information for videodb sdk""" + + +__version__ = "0.2.1" +__title__ = "videodb" +__author__ = "videodb" +__email__ = "contact@videodb.io" +__url__ = "https://github.com/video-db/videodb-python" +__license__ = "Apache License 2.0" diff --git a/videodb/__init__.py b/videodb/__init__.py index 1657f43..7aa45a7 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -7,6 +7,8 @@ from videodb._utils._video import play_stream from videodb._constants import ( VIDEO_DB_API, + IndexType, + SceneExtractionType, MediaType, SearchType, SubtitleAlignment, @@ -24,13 +26,12 @@ logger: logging.Logger = logging.getLogger("videodb") -__version__ = "0.1.2" -__author__ = "videodb" __all__ = [ "VideodbError", "AuthenticationError", "InvalidRequestError", + "IndexType", "SearchError", "play_stream", "MediaType", @@ -39,6 +40,7 @@ "SubtitleBorderStyle", "SubtitleStyle", "TextStyle", + "SceneExtractionType", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index dd4afcc..7f12586 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -18,10 +18,15 @@ class SearchType: class IndexType: - semantic = "semantic" + spoken_word = "spoken_word" scene = "scene" +class SceneExtractionType: + shot_based = "shot" + time_based = "time" + + class Workflows: add_subtitles = "add_subtitles" @@ -51,6 +56,10 @@ class ApiPath: billing = "billing" usage = "usage" invoices = "invoices" + scenes = "scenes" + scene = "scene" + frame = "frame" + describe = "describe" class Status: diff --git a/videodb/_utils/_http_client.py b/videodb/_utils/_http_client.py index 35eb5c5..8633ebb 100644 --- a/videodb/_utils/_http_client.py +++ b/videodb/_utils/_http_client.py @@ -19,6 +19,7 @@ from videodb.exceptions import ( AuthenticationError, InvalidRequestError, + RequestTimeoutError, ) logger = logging.getLogger(__name__) @@ -31,6 +32,7 @@ def __init__( self, api_key: str, base_url: str, + version: str, max_retries: Optional[int] = HttpClientDefaultValues.max_retries, ) -> None: """Create a new http client instance @@ -49,8 +51,13 @@ def __init__( adapter = HTTPAdapter(max_retries=retries) self.session.mount("http://", adapter) self.session.mount("https://", adapter) + self.version = version self.session.headers.update( - {"x-access-token": api_key, "Content-Type": "application/json"} + { + "x-access-token": api_key, + "x-videodb-client": f"videodb-python/{self.version}", + "Content-Type": "application/json", + } ) self.base_url = base_url self.show_progress = False @@ -109,8 +116,8 @@ def _handle_request_error(self, e: requests.exceptions.RequestException) -> None ) from None elif isinstance(e, requests.exceptions.Timeout): - raise InvalidRequestError( - "Invalid request: Request timed out", e.response + raise RequestTimeoutError( + "Timeout error: Request timed out", e.response ) from None elif isinstance(e, requests.exceptions.ConnectionError): diff --git a/videodb/client.py b/videodb/client.py index 39bf38b..a118f57 100644 --- a/videodb/client.py +++ b/videodb/client.py @@ -5,7 +5,7 @@ Union, List, ) - +from videodb.__about__ import __version__ from videodb._constants import ( ApiPath, ) @@ -28,7 +28,7 @@ def __init__(self, api_key: str, base_url: str) -> None: self.api_key = api_key self.base_url = base_url self.collection_id = "default" - super().__init__(api_key, base_url) + super().__init__(api_key=api_key, base_url=base_url, version=__version__) def get_collection(self, collection_id: Optional[str] = "default") -> Collection: collection_data = self.get(path=f"{ApiPath.collection}/{collection_id}") diff --git a/videodb/collection.py b/videodb/collection.py index fcafa4d..b610009 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -10,6 +10,7 @@ ) from videodb._constants import ( ApiPath, + IndexType, SearchType, ) from videodb.video import Video @@ -100,14 +101,17 @@ def search( self, query: str, search_type: Optional[str] = SearchType.semantic, + index_type: Optional[str] = IndexType.spoken_word, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, ) -> SearchResult: search = SearchFactory(self._connection).get_search(search_type) return search.search_inside_collection( collection_id=self.id, query=query, + search_type=search_type, + index_type=index_type, result_threshold=result_threshold, score_threshold=score_threshold, dynamic_score_percentage=dynamic_score_percentage, diff --git a/videodb/exceptions.py b/videodb/exceptions.py index 16749b4..2b70008 100644 --- a/videodb/exceptions.py +++ b/videodb/exceptions.py @@ -37,6 +37,16 @@ def __init__(self, message, response=None): self.response = response +class RequestTimeoutError(VideodbError): + """ + Raised when a request times out. + """ + + def __init__(self, message, response=None): + super(RequestTimeoutError, self).__init__(message) + self.response = response + + class SearchError(VideodbError): """ Raised when a search is invalid. diff --git a/videodb/image.py b/videodb/image.py index d7e5e0c..5a97b87 100644 --- a/videodb/image.py +++ b/videodb/image.py @@ -22,3 +22,50 @@ def __repr__(self) -> str: def delete(self) -> None: self._connection.delete(f"{ApiPath.image}/{self.id}") + + +class Frame(Image): + def __init__( + self, + _connection, + id: str, + video_id: str, + scene_id: str, + url: str, + frame_time: float, + description: str, + ): + super().__init__(_connection=_connection, id=id, collection_id=None, url=url) + self.scene_id = scene_id + self.video_id = video_id + self.frame_time = frame_time + self.description = description + + def __repr__(self) -> str: + return ( + f"Frame(" + f"id={self.id}, " + f"video_id={self.video_id}, " + f"scene_id={self.scene_id}, " + f"url={self.url}, " + f"frame_time={self.frame_time}, " + f"description={self.description})" + ) + + def to_json(self): + return { + "id": self.id, + "video_id": self.video_id, + "scene_id": self.scene_id, + "url": self.url, + "frame_time": self.frame_time, + "description": self.description, + } + + def describe(self, prompt: str = None, model_name=None): + description_data = self._connection.post( + path=f"{ApiPath.video}/{self.video_id}/{ApiPath.frame}/{self.id}/{ApiPath.describe}", + data={"prompt": prompt, "model_name": model_name}, + ) + self.description = description_data.get("description", None) + return self.description diff --git a/videodb/scene.py b/videodb/scene.py new file mode 100644 index 0000000..3bb1028 --- /dev/null +++ b/videodb/scene.py @@ -0,0 +1,86 @@ +from typing import List + +from videodb._constants import ApiPath + +from videodb.image import Frame + + +class Scene: + def __init__( + self, + video_id: str, + start: float, + end: float, + description: str, + id: str = None, + frames: List[Frame] = [], + connection=None, + ): + self.id = id + self.video_id = video_id + self.start = start + self.end = end + self.frames: List[Frame] = frames + self.description = description + self._connection = connection + + def __repr__(self) -> str: + return ( + f"Scene(" + f"id={self.id}, " + f"video_id={self.video_id}, " + f"start={self.start}, " + f"end={self.end}, " + f"frames={self.frames}, " + f"description={self.description})" + ) + + def to_json(self): + return { + "id": self.id, + "video_id": self.video_id, + "start": self.start, + "end": self.end, + "frames": [frame.to_json() for frame in self.frames], + "description": self.description, + } + + def describe(self, prompt: str = None, model_name=None) -> None: + if self._connection is None: + raise ValueError("Connection is required to describe a scene") + description_data = self._connection.post( + path=f"{ApiPath.video}/{self.video_id}/{ApiPath.scene}/{self.id}/{ApiPath.describe}", + data={"prompt": prompt, "model_name": model_name}, + ) + self.description = description_data.get("description", None) + return self.description + + +class SceneCollection: + def __init__( + self, + _connection, + id: str, + video_id: str, + config: dict, + scenes: List[Scene], + ) -> None: + self._connection = _connection + self.id = id + self.video_id = video_id + self.config: dict = config + self.scenes: List[Scene] = scenes + + def __repr__(self) -> str: + return ( + f"SceneCollection(" + f"id={self.id}, " + f"video_id={self.video_id}, " + f"config={self.config}, " + f"scenes={self.scenes})" + ) + + def delete(self) -> None: + self._connection.delete( + path=f"{ApiPath.video}/{self.video_id}/{ApiPath.scenes}/{self.id}" + ) diff --git a/videodb/search.py b/videodb/search.py index 81ff27b..168d10f 100644 --- a/videodb/search.py +++ b/videodb/search.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from videodb._utils._video import play_stream from videodb._constants import ( + IndexType, SearchType, ApiPath, SemanticSearchDefaultValues, @@ -109,20 +110,25 @@ def search_inside_video( self, video_id: str, query: str, + search_type: str, + index_type: str, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.video}/{video_id}/{ApiPath.search}", data={ - "index_type": SearchType.semantic, + "search_type": search_type, + "index_type": index_type, "query": query, "score_threshold": score_threshold or SemanticSearchDefaultValues.score_threshold, "result_threshold": result_threshold or SemanticSearchDefaultValues.result_threshold, + "dynamic_score_percentage": dynamic_score_percentage, + **kwargs, }, ) return SearchResult(self._connection, **search_data) @@ -131,20 +137,25 @@ def search_inside_collection( self, collection_id: str, query: str, + search_type: str, + index_type: str, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.collection}/{collection_id}/{ApiPath.search}", data={ - "index_type": SearchType.semantic, + "search_type": search_type, + "index_type": index_type, "query": query, "score_threshold": score_threshold or SemanticSearchDefaultValues.score_threshold, "result_threshold": result_threshold or SemanticSearchDefaultValues.result_threshold, + "dynamic_score_percentage": dynamic_score_percentage, + **kwargs, }, ) return SearchResult(self._connection, **search_data) @@ -158,15 +169,18 @@ def search_inside_video( self, video_id: str, query: str, + search_type: str, + index_type: str, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.video}/{video_id}/{ApiPath.search}", data={ - "index_type": SearchType.keyword, + "search_type": search_type, + "index_type": index_type, "query": query, "score_threshold": score_threshold, "result_threshold": result_threshold, @@ -186,18 +200,23 @@ def search_inside_video( self, video_id: str, query: str, + search_type: str, + index_type: str, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.video}/{video_id}/{ApiPath.search}", data={ - "index_type": SearchType.scene, + "search_type": search_type, + "index_type": IndexType.scene, "query": query, "score_threshold": score_threshold, "result_threshold": result_threshold, + "dynamic_score_percentage": dynamic_score_percentage, + **kwargs, }, ) return SearchResult(self._connection, **search_data) diff --git a/videodb/video.py b/videodb/video.py index 685f8a5..5098cbf 100644 --- a/videodb/video.py +++ b/videodb/video.py @@ -3,11 +3,13 @@ from videodb._constants import ( ApiPath, IndexType, + SceneExtractionType, SearchType, SubtitleStyle, Workflows, ) -from videodb.image import Image +from videodb.image import Image, Frame +from videodb.scene import Scene, SceneCollection from videodb.search import SearchFactory, SearchResult from videodb.shot import Shot @@ -47,17 +49,22 @@ def search( self, query: str, search_type: Optional[str] = SearchType.semantic, + index_type: Optional[str] = IndexType.spoken_word, result_threshold: Optional[int] = None, - score_threshold: Optional[int] = None, - dynamic_score_percentage: Optional[int] = None, + score_threshold: Optional[float] = None, + dynamic_score_percentage: Optional[float] = None, + **kwargs, ) -> SearchResult: search = SearchFactory(self._connection).get_search(search_type) return search.search_inside_video( video_id=self.id, query=query, + search_type=search_type, + index_type=index_type, result_threshold=result_threshold, score_threshold=score_threshold, dynamic_score_percentage=dynamic_score_percentage, + **kwargs, ) def delete(self) -> None: @@ -148,7 +155,7 @@ def index_spoken_words( self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.index}", data={ - "index_type": IndexType.semantic, + "index_type": IndexType.spoken_word, "language_code": language_code, "force": force, "callback_url": callback_url, @@ -156,22 +163,6 @@ def index_spoken_words( show_progress=True, ) - def index_scenes( - self, - force: bool = False, - prompt: str = None, - callback_url: str = None, - ) -> None: - self._connection.post( - path=f"{ApiPath.video}/{self.id}/{ApiPath.index}", - data={ - "index_type": IndexType.scene, - "force": force, - "prompt": prompt, - "callback_url": callback_url, - }, - ) - def get_scenes(self) -> Union[list, None]: if self.scenes: return self.scenes @@ -184,14 +175,131 @@ def get_scenes(self) -> Union[list, None]: self.scenes = scene_data return scene_data if scene_data else None - def delete_scene_index(self) -> None: - self._connection.post( - path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.delete}", + def _format_scene_collection(self, scene_collection_data: dict) -> SceneCollection: + scenes = [] + for scene in scene_collection_data.get("scenes", []): + frames = [] + for frame in scene.get("frames", []): + frame = Frame( + self._connection, + frame.get("frame_id"), + self.id, + scene.get("scene_id"), + frame.get("url"), + frame.get("frame_time"), + frame.get("description"), + ) + frames.append(frame) + scene = Scene( + video_id=self.id, + start=scene.get("start"), + end=scene.get("end"), + description=scene.get("description"), + id=scene.get("scene_id"), + frames=frames, + connection=self._connection, + ) + scenes.append(scene) + + return SceneCollection( + self._connection, + scene_collection_data.get("scene_collection_id"), + self.id, + scene_collection_data.get("config", {}), + scenes, + ) + + def extract_scenes( + self, + extraction_type: SceneExtractionType = SceneExtractionType.shot_based, + extraction_config: dict = {}, + force: bool = False, + callback_url: str = None, + ) -> Optional[SceneCollection]: + scenes_data = self._connection.post( + path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}", data={ - "index_type": IndexType.scene, + "extraction_type": extraction_type, + "extraction_config": extraction_config, + "force": force, + "callback_url": callback_url, + }, + ) + if not scenes_data: + return None + return self._format_scene_collection(scenes_data.get("scene_collection")) + + def get_scene_collection(self, collection_id: str) -> Optional[SceneCollection]: + if not collection_id: + raise ValueError("collection_id is required") + scenes_data = self._connection.get( + path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}/{collection_id}" + ) + if not scenes_data: + return None + return self._format_scene_collection(scenes_data.get("scene_collection")) + + def list_scene_collection(self): + scene_collections_data = self._connection.get( + path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}" + ) + return scene_collections_data.get("scene_collections", []) + + def delete_scene_collection(self, collection_id: str) -> None: + if not collection_id: + raise ValueError("collection_id is required") + self._connection.delete( + path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}/{collection_id}" + ) + + def index_scenes( + self, + extraction_type: SceneExtractionType = SceneExtractionType.shot_based, + extraction_config: Dict = {}, + prompt: Optional[str] = None, + model_name: Optional[str] = None, + model_config: Optional[Dict] = None, + name: Optional[str] = None, + scenes: Optional[List[Scene]] = None, + callback_url: Optional[str] = None, + ) -> Optional[str]: + scenes_data = self._connection.post( + path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}", + data={ + "extraction_type": extraction_type, + "extraction_config": extraction_config, + "prompt": prompt, + "model_name": model_name, + "model_config": model_config, + "name": name, + "scenes": [scene.to_json() for scene in scenes] if scenes else None, + "callback_url": callback_url, }, ) - self.scenes = None + if not scenes_data: + return None + return scenes_data.get("scene_index_id") + + def list_scene_index(self) -> List: + index_data = self._connection.get( + path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}" + ) + return index_data.get("scene_indexes", []) + + def get_scene_index(self, scene_index_id: str) -> Optional[List]: + index_data = self._connection.get( + path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}/{scene_index_id}" + ) + if not index_data: + return None + return index_data.get("scene_index_records", []) + + def delete_scene_index(self, scene_index_id: str) -> None: + if not scene_index_id: + raise ValueError("scene_index_id is required") + self._connection.delete( + path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}/{scene_index_id}" + ) def add_subtitle(self, style: SubtitleStyle = SubtitleStyle()) -> str: if not isinstance(style, SubtitleStyle):