diff --git a/videodb/__init__.py b/videodb/__init__.py index f5125e4..51225eb 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -12,6 +12,7 @@ SubtitleAlignment, SubtitleBorderStyle, SubtitleStyle, + TextStyle, ) from videodb.client import Connection from videodb.exceptions import ( @@ -23,7 +24,7 @@ logger: logging.Logger = logging.getLogger("videodb") -__version__ = "0.0.5" +__version__ = "0.1.0" __author__ = "videodb" __all__ = [ @@ -37,6 +38,7 @@ "SubtitleAlignment", "SubtitleBorderStyle", "SubtitleStyle", + "TextStyle", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index 7c2d317..50fe068 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -1,5 +1,5 @@ """Constants used in the videodb package.""" - +from typing import Union from dataclasses import dataclass VIDEO_DB_API: str = "https://api.videodb.io" @@ -14,10 +14,12 @@ class MediaType: class SearchType: semantic = "semantic" keyword = "keyword" + scene = "scene" class IndexType: semantic = "semantic" + scene = "scene" class Workflows: @@ -44,6 +46,7 @@ class ApiPath: compile = "compile" workflow = "workflow" timeline = "timeline" + delete = "delete" class Status: @@ -103,3 +106,32 @@ class SubtitleStyle: margin_l: int = 10 margin_r: int = 10 margin_v: int = 10 + + +@dataclass +class TextStyle: + fontsize: int = 24 + fontcolor: str = "black" + fontcolor_expr: str = "" + alpha: float = 1.0 + font: str = "Sans" + box: bool = True + boxcolor: str = "white" + boxborderw: str = "10" + boxw: int = 0 + boxh: int = 0 + line_spacing: int = 0 + text_align: str = "T" + y_align: str = "text" + borderw: int = 0 + bordercolor: str = "black" + expansion: str = "normal" + basetime: int = 0 + fix_bounds: bool = False + text_shaping: bool = True + shadowcolor: str = "black" + shadowx: int = 0 + shadowy: int = 0 + tabsize: int = 4 + x: Union[str, int] = "(main_w-text_w)/2" + y: Union[str, int] = "(main_h-text_h)/2" diff --git a/videodb/_utils/_http_client.py b/videodb/_utils/_http_client.py index ae8f0d0..4555411 100644 --- a/videodb/_utils/_http_client.py +++ b/videodb/_utils/_http_client.py @@ -133,7 +133,7 @@ def _get_output(self, url: str): response_json.get("status") == Status.in_progress or response_json.get("status") == Status.processing ): - percentage = response_json.get("data").get("percentage") + 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) @@ -169,7 +169,7 @@ def _parse_response(self, response: requests.Response): bar_format="{l_bar}{bar:100}{r_bar}{bar:-100b}", ) response_json = self._get_output( - response_json.get("data").get("output_url") + response_json.get("data", {}).get("output_url") ) if response_json.get("success"): return response_json.get("data") diff --git a/videodb/asset.py b/videodb/asset.py index e64a103..6061b4b 100644 --- a/videodb/asset.py +++ b/videodb/asset.py @@ -1,9 +1,10 @@ import copy import logging +import uuid from typing import Optional, Union -from videodb._constants import MaxSupported +from videodb._constants import MaxSupported, TextStyle logger = logging.getLogger(__name__) @@ -32,8 +33,8 @@ class VideoAsset(MediaAsset): def __init__( self, asset_id: str, - start: Optional[int] = 0, - end: Optional[Union[int, None]] = None, + start: Optional[float] = 0, + end: Optional[float] = None, ) -> None: super().__init__(asset_id) self.start: int = start @@ -55,8 +56,8 @@ class AudioAsset(MediaAsset): def __init__( self, asset_id: str, - start: Optional[int] = 0, - end: Optional[Union[int, None]] = None, + start: Optional[float] = 0, + end: Optional[float] = None, disable_other_tracks: Optional[bool] = True, fade_in_duration: Optional[Union[int, float]] = 0, fade_out_duration: Optional[Union[int, float]] = 0, @@ -117,3 +118,33 @@ def __repr__(self) -> str: f"y={self.y}, " f"duration={self.duration})" ) + + +class TextAsset(MediaAsset): + def __init__( + self, + text: str, + duration: Optional[int] = None, + style: TextStyle = TextStyle(), + ) -> None: + super().__init__(f"txt-{str(uuid.uuid4())}") + self.text = text + self.duration = duration + self.style: TextStyle = style + + def to_json(self) -> dict: + return { + "text": copy.deepcopy(self.text), + "asset_id": copy.deepcopy(self.asset_id), + "duration": copy.deepcopy(self.duration), + "style": copy.deepcopy(self.style.__dict__), + } + + def __repr__(self) -> str: + return ( + f"TextAsset(" + f"text={self.text}, " + f"asset_id={self.asset_id}, " + f"duration={self.duration}, " + f"style={self.style})" + ) diff --git a/videodb/collection.py b/videodb/collection.py index e3b2324..8073ec9 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -77,11 +77,11 @@ def search( ) -> SearchResult: search = SearchFactory(self._connection).get_search(search_type) return search.search_inside_collection( - self.id, - query, - result_threshold, - score_threshold, - dynamic_score_percentage, + collection_id=self.id, + query=query, + result_threshold=result_threshold, + score_threshold=score_threshold, + dynamic_score_percentage=dynamic_score_percentage, ) def upload( diff --git a/videodb/search.py b/videodb/search.py index a19eb0d..81ff27b 100644 --- a/videodb/search.py +++ b/videodb/search.py @@ -112,6 +112,7 @@ def search_inside_video( result_threshold: Optional[int] = None, score_threshold: Optional[int] = None, dynamic_score_percentage: Optional[int] = None, + **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.video}/{video_id}/{ApiPath.search}", @@ -133,6 +134,7 @@ def search_inside_collection( result_threshold: Optional[int] = None, score_threshold: Optional[int] = None, dynamic_score_percentage: Optional[int] = None, + **kwargs, ): search_data = self._connection.post( path=f"{ApiPath.collection}/{collection_id}/{ApiPath.search}", @@ -176,7 +178,39 @@ def search_inside_collection(self, **kwargs): raise NotImplementedError("Keyword search will be implemented in the future") -search_type = {SearchType.semantic: SemanticSearch, SearchType.keyword: KeywordSearch} +class SceneSearch(Search): + def __init__(self, _connection): + self._connection = _connection + + def search_inside_video( + self, + video_id: str, + query: str, + result_threshold: Optional[int] = None, + score_threshold: Optional[int] = None, + dynamic_score_percentage: Optional[int] = None, + **kwargs, + ): + search_data = self._connection.post( + path=f"{ApiPath.video}/{video_id}/{ApiPath.search}", + data={ + "index_type": SearchType.scene, + "query": query, + "score_threshold": score_threshold, + "result_threshold": result_threshold, + }, + ) + return SearchResult(self._connection, **search_data) + + def search_inside_collection(self, **kwargs): + raise NotImplementedError("Scene search will be implemented in the future") + + +search_type = { + SearchType.semantic: SemanticSearch, + SearchType.keyword: KeywordSearch, + SearchType.scene: SceneSearch, +} class SearchFactory: diff --git a/videodb/timeline.py b/videodb/timeline.py index 96b66bf..c4b63ce 100644 --- a/videodb/timeline.py +++ b/videodb/timeline.py @@ -1,7 +1,7 @@ from typing import Union from videodb._constants import ApiPath -from videodb.asset import VideoAsset, AudioAsset, ImageAsset +from videodb.asset import VideoAsset, AudioAsset, ImageAsset, TextAsset class Timeline(object): @@ -23,14 +23,22 @@ def to_json(self) -> dict: timeline_json.append(asset.to_json()) return {"timeline": timeline_json} - def add_inline(self, asset: Union[VideoAsset]) -> None: + def add_inline(self, asset: VideoAsset) -> None: if not isinstance(asset, VideoAsset): raise ValueError("asset must be of type VideoAsset") self._timeline.append(asset) - def add_overlay(self, start: int, asset: Union[AudioAsset, ImageAsset]) -> None: - if not isinstance(asset, AudioAsset) and not isinstance(asset, ImageAsset): - raise ValueError("asset must be of type AudioAsset or ImageAsset") + def add_overlay( + self, start: int, asset: Union[AudioAsset, ImageAsset, TextAsset] + ) -> None: + if ( + not isinstance(asset, AudioAsset) + and not isinstance(asset, ImageAsset) + and not isinstance(asset, TextAsset) + ): + raise ValueError( + "asset must be of type AudioAsset, ImageAsset or TextAsset" + ) self._timeline.append((start, asset)) def generate_stream(self) -> str: diff --git a/videodb/video.py b/videodb/video.py index a5e3fc0..a2bcbab 100644 --- a/videodb/video.py +++ b/videodb/video.py @@ -1,11 +1,11 @@ -from typing import Optional, List, Dict, Tuple +from typing import Optional, Union, List, Dict, Tuple from videodb._utils._video import play_stream from videodb._constants import ( ApiPath, - SearchType, IndexType, - Workflows, + SearchType, SubtitleStyle, + Workflows, ) from videodb.search import SearchFactory, SearchResult from videodb.shot import Shot @@ -24,6 +24,7 @@ def __init__(self, _connection, id: str, collection_id: str, **kwargs) -> None: self.length = float(kwargs.get("length", 0.0)) self.transcript = kwargs.get("transcript", None) self.transcript_text = kwargs.get("transcript_text", None) + self.scenes = kwargs.get("scenes", None) def __repr__(self) -> str: return ( @@ -51,11 +52,11 @@ def search( ) -> SearchResult: search = SearchFactory(self._connection).get_search(search_type) return search.search_inside_video( - self.id, - query, - result_threshold, - score_threshold, - dynamic_score_percentage, + video_id=self.id, + query=query, + result_threshold=result_threshold, + score_threshold=score_threshold, + dynamic_score_percentage=dynamic_score_percentage, ) def delete(self) -> None: @@ -130,6 +131,43 @@ def index_spoken_words(self) -> None: }, ) + 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 + scene_data = self._connection.get( + path=f"{ApiPath.video}/{self.id}/{ApiPath.index}", + params={ + "index_type": IndexType.scene, + }, + ) + 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}", + data={ + "index_type": IndexType.scene, + }, + ) + self.scenes = None + def add_subtitle(self, style: SubtitleStyle = SubtitleStyle()) -> str: if not isinstance(style, SubtitleStyle): raise ValueError("style must be of type SubtitleStyle")