├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
├── pull_request_template.md
└── workflows
│ ├── ci.yaml
│ └── trigger-agent-toolkit-update.yaml
├── .gitignore
├── LICENSE
├── README.md
├── requirements-dev.txt
├── requirements.txt
├── setup.py
├── tests
├── __init__.py
└── test_exceptions.py
└── videodb
├── __about__.py
├── __init__.py
├── _constants.py
├── _upload.py
├── _utils
├── __init__.py
├── _http_client.py
└── _video.py
├── asset.py
├── audio.py
├── client.py
├── collection.py
├── exceptions.py
├── image.py
├── rtstream.py
├── scene.py
├── search.py
├── shot.py
├── timeline.py
└── video.py
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Create a report to help us improve
3 | labels: ['bug']
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | Thanks for taking the time to fill out this bug report!
9 | - type: checkboxes
10 | attributes:
11 | label: Confirm this is a new bug report
12 | description: >
13 | Select the checkboxes that apply to this bug report. If you're not sure about any of these, don't worry! We'll help you figure it out.
14 | options:
15 | - label: Possible new bug in VideoDB Python Client
16 | required: false
17 | - label: Potential new bug in VideoDB API
18 | required: false
19 | - label: I've checked the current issues, and there's no record of this bug
20 | required: false
21 | - type: textarea
22 | attributes:
23 | label: Current Behavior
24 | description: >
25 | A clear and concise description of what the bug is.
26 | placeholder: >
27 | I intended to perform action X, but unexpectedly encountered outcome Y.
28 | validations:
29 | required: true
30 | - type: textarea
31 | attributes:
32 | label: Expected Behavior
33 | description: >
34 | A clear and concise description of what you expected to happen.
35 | placeholder: >
36 | I expected outcome Y to occur.
37 | validations:
38 | required: true
39 | - type: textarea
40 | attributes:
41 | label: Steps to Reproduce
42 | description: >
43 | Steps to reproduce the behavior:
44 | placeholder: |
45 | 1. Fetch a '...'
46 | 2. Update the '....'
47 | 3. See error
48 | validations:
49 | required: true
50 | - type: textarea
51 | attributes:
52 | label: Relevant Logs and/or Screenshots
53 | description: >
54 | If applicable, add logs and/or screenshots to help explain your problem.
55 | validations:
56 | required: false
57 | - type: textarea
58 | attributes:
59 | label: Environment
60 | description: |
61 | Please complete the following information:
62 | eg:
63 | - OS: Ubuntu 20.04
64 | - Python: 3.9.1
65 | - Videodb: 0.0.1
66 | value: |
67 | - OS:
68 | - Python:
69 | - Videodb:
70 | validations:
71 | required: false
72 | - type: textarea
73 | attributes:
74 | label: Additional Context
75 | description: >
76 | Add any other context about the problem here.
77 | validations:
78 | required: false
79 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature
2 | description: Submit a proposal/request for a new feature
3 | labels: ['enhancement']
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | Thanks for taking the time to fill out this feature request!
9 | - type: checkboxes
10 | attributes:
11 | label: Confirm this is a new feature request
12 | description: >
13 | Select the checkboxes that apply to this feature request. If you're not sure about any of these, don't worry! We'll help you figure it out.
14 | options:
15 | - label: Possible new feature in VideoDB Python Client
16 | required: false
17 | - label: Potential new feature in VideoDB API
18 | required: false
19 | - label: I've checked the current issues, and there's no record of this feature request
20 | required: false
21 | - type: textarea
22 | attributes:
23 | label: Describe the feature
24 | description: >
25 | A clear and concise description of what the feature is and why it's needed.
26 | validations:
27 | required: true
28 | - type: textarea
29 | attributes:
30 | label: Describe the solution you'd like
31 | description: |
32 | A clear and concise description of what you want to happen.
33 | validations:
34 | required: true
35 | - type: textarea
36 | attributes:
37 | label: Describe alternatives you've considered
38 | description: >
39 | A clear and concise description of any alternative solutions or features you've considered.
40 | validations:
41 | required: false
42 | - type: textarea
43 | attributes:
44 | label: Additional Context
45 | description: >
46 | Add any other context about the feature request here.
47 | validations:
48 | required: false
49 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Pull Request
2 |
3 | **Description:**
4 | Describe the purpose of this pull request.
5 |
6 | **Changes:**
7 | - [ ] Feature A
8 | - [ ] Bugfix B
9 |
10 | **Related Issues:**
11 | - Closes #123
12 | - Addresses #456
13 |
14 | **Testing:**
15 | Describe any testing steps that have been taken or are necessary.
16 | Make sure to take in account any existing code change that require some feature to be re-tested.
17 |
18 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration Workflow
2 | # Trigger the workflow on push or pull request, on main branch
3 |
4 | on:
5 | push:
6 | branches: [ main ]
7 | pull_request:
8 | branches: [ main ]
9 |
10 | # Define environment variables
11 | env:
12 | PYTHON_VERSION: 3.9
13 | APP_DIR: ./
14 |
15 | jobs:
16 | lint:
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Set up Python 3
21 | uses: actions/setup-python@v2
22 | with:
23 | python-version: ${{ env.PYTHON_VERSION }}
24 |
25 | - name: Install dependencies
26 | run: |
27 | python -m pip install --upgrade pip
28 | pip install -r requirements-dev.txt
29 |
30 | - name: Run linter
31 | run: |
32 | ruff check ${{ env.APP_DIR }}
33 |
34 | test:
35 | runs-on: ubuntu-latest
36 | steps:
37 | - uses: actions/checkout@v2
38 | - name: Set up Python 3
39 | uses: actions/setup-python@v2
40 | with:
41 | python-version: ${{ env.PYTHON_VERSION }}
42 |
43 | - name: Install dependencies
44 | run: |
45 | python -m pip install --upgrade pip
46 | pip install -r requirements.txt
47 | pip install -r requirements-dev.txt
48 |
49 | - name: Run tests
50 | run: |
51 | python -m pytest tests/
52 |
53 | build:
54 | runs-on: ubuntu-latest
55 | steps:
56 | - uses: actions/checkout@v2
57 | - name: Set up Python 3
58 | uses: actions/setup-python@v2
59 | with:
60 | python-version: ${{ env.PYTHON_VERSION }}
61 |
62 | - name: Install dependencies
63 | run: |
64 | python -m pip install --upgrade pip
65 | pip install -r requirements.txt
66 | pip install -r requirements-dev.txt
67 |
68 | - name: Build
69 | run: |
70 | python setup.py sdist bdist_wheel
71 | twine check dist/*
72 |
--------------------------------------------------------------------------------
/.github/workflows/trigger-agent-toolkit-update.yaml:
--------------------------------------------------------------------------------
1 | name: Trigger Agent Toolkit Update
2 |
3 | on:
4 | pull_request:
5 | types: [closed]
6 |
7 | jobs:
8 | trigger-videodb-helper-update:
9 | if: ${{ github.event.pull_request.merged && github.event.pull_request.base.ref == 'main' }}
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Trigger Agent Toolkit Update workflow via repository_dispatch
13 | run: |
14 | curl -X POST -H "Accept: application/vnd.github+json" \
15 | -H "Authorization: Bearer ${{ secrets.AGENT_TOOLKIT_TOKEN }}" \
16 | -H "X-GitHub-Api-Version: 2022-11-28" \
17 | https://api.github.com/repos/video-db/agent-toolkit/dispatches \
18 | -d '{"event_type": "sdk-context-update", "client_payload": {"pr_number": ${{ github.event.pull_request.number }}}}'
19 |
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | __pycache__/*
3 | */__pycache__
4 | *.pyc
5 | *.log
6 | .DS_Store
7 | log/
8 | *.out
9 | *.zip
10 | .idea/*
11 | .env
12 | build/*
13 | dist/*
14 | *.egg-info/*
15 | venv/
16 | .vscode/*
17 | example.ipynb
18 | example.py
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | [![PyPI version][pypi-shield]][pypi-url]
8 | [![Stargazers][stars-shield]][stars-url]
9 | [![Issues][issues-shield]][issues-url]
10 | [![Website][website-shield]][website-url]
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
VideoDB Python SDK
20 |
21 |
22 | Video Database for your AI Applications
23 |
24 | Explore the docs »
25 |
26 |
27 | View Demo
28 | ·
29 | Report Bug
30 | ·
31 | Request Feature
32 |
33 |
34 |
35 |
36 |
37 | # VideoDB Python SDK
38 |
39 | VideoDB Python SDK allows you to interact with the VideoDB serverless database. Manage videos as intelligent data, not files. It's scalable, cost-efficient & optimized for AI applications and LLM integration.
40 |
41 |
42 |
44 |
45 |
46 |
47 | ## Installation
48 |
49 | To install the package, run the following command in your terminal:
50 |
51 | ```
52 | pip install videodb
53 | ```
54 |
55 |
56 |
57 | ## Quick Start
58 |
59 | ### Creating a Connection
60 |
61 | Get an API key from the [VideoDB console](https://console.videodb.io). Free for first 50 uploads _(No credit card required)_.
62 |
63 | ```python
64 | import videodb
65 | conn = videodb.connect(api_key="YOUR_API_KEY")
66 | ```
67 |
68 | ## Working with a Single Video
69 |
70 | ---
71 |
72 | ### ⬆️ Uploading a Video
73 |
74 | Now that you have established a connection to VideoDB, you can upload your videos using `conn.upload()`.
75 | You can directly upload from `youtube`, `any public url`, `S3 bucket` or a `local file path`. A default collection is created when you create your first connection.
76 |
77 | `upload` method returns a `Video` object.
78 |
79 | ```python
80 | # Upload a video by url
81 | video = conn.upload(url="https://www.youtube.com/watch?v=WDv4AWk0J3U")
82 |
83 | # Upload a video from file system
84 | video_f = conn.upload(file_path="./my_video.mp4")
85 |
86 | ```
87 |
88 | ### 📺 View your Video
89 |
90 | Once uploaded, your video is immediately available for viewing in 720p resolution. ⚡️
91 |
92 | - Generate a streamable url for the video using video.generate_stream()
93 | - Preview the video using video.play(). This will open the video in your default browser/notebook
94 |
95 | ```python
96 | video.generate_stream()
97 | video.play()
98 | ```
99 |
100 | ### ⛓️ Stream Specific Sections of Videos
101 |
102 | You can easily clip specific sections of a video by passing a timeline of the start and end timestamps (in seconds) as a parameter.
103 | For example, this will generate and play a compilation of the first `10 seconds` and the clip between the `120th` and the `140th` second.
104 |
105 | ```python
106 | stream_link = video.generate_stream(timeline=[[0,10], [120,140]])
107 | play_stream(stream_link)
108 | ```
109 |
110 | ### 🔍 Search Inside a Video
111 |
112 | To search bits inside a video, you have to `index` the video first. This can be done by a simple command.
113 | _P.S. Indexing may take some time for longer videos._
114 |
115 | ```python
116 | video.index_spoken_words()
117 | result = video.search("Morning Sunlight")
118 | result.play()
119 | video.get_transcript()
120 | ```
121 |
122 | `Videodb` is launching more indexing options in upcoming versions. As of now you can try the `semantic` index - Index by spoken words.
123 |
124 | In the future you'll be able to index videos using:
125 |
126 | 1. **Scene** - Visual concepts and events.
127 | 2. **Faces**.
128 | 3. **Specific domain Index** like Football, Baseball, Drone footage, Cricket etc.
129 |
130 | ### Viewing Search Results
131 |
132 | `video.search()` returns a `SearchResults` object, which contains the sections or as we call them, `shots` of videos which semantically match your search query.
133 |
134 | - `result.get_shots()` Returns a list of Shot(s) that matched the search query.
135 | - `result.play()` Returns a playable url for the video (similar to video.play(); you can open this link in the browser, or embed it into your website using an iframe).
136 |
137 | ## RAG: Search inside Multiple Videos
138 |
139 | ---
140 |
141 | `VideoDB` can store and search inside multiple videos with ease. By default, videos are uploaded to your default collection.
142 |
143 | ### 🔄 Using Collection to Upload Multiple Videos
144 |
145 | ```python
146 | # Get the default collection
147 | coll = conn.get_collection()
148 |
149 | # Upload Videos to a collection
150 | coll.upload(url="https://www.youtube.com/watch?v=lsODSDmY4CY")
151 | coll.upload(url="https://www.youtube.com/watch?v=vZ4kOr38JhY")
152 | coll.upload(url="https://www.youtube.com/watch?v=uak_dXHh6s4")
153 | ```
154 |
155 | - `conn.get_collection()` : Returns a Collection object; the default collection.
156 | - `coll.get_videos()` : Returns a list of Video objects; all videos in the collections.
157 | - `coll.get_video(video_id)`: Returns a Video object, corresponding video from the provided `video_id`.
158 | - `coll.delete_video(video_id)`: Deletes the video from the Collection.
159 |
160 | ### 📂 Search Inside Collection
161 |
162 | You can simply Index all the videos in a collection and use the search method to find relevant results.
163 | Here we are indexing the spoken content of a collection and performing semantic search.
164 |
165 | ```python
166 | # Index all videos in collection
167 | for video in coll.get_videos():
168 | video.index_spoken_words()
169 |
170 | # search in the collection of videos
171 | results = coll.search(query = "What is Dopamine?")
172 | results.play()
173 | ```
174 |
175 | The result here has all the matching bits in a single stream from your collection. You can use these results in your application right away.
176 |
177 | ### 🌟 Explore the Video object
178 |
179 | There are multiple methods available on a Video Object, that can be helpful for your use-case.
180 |
181 | **Get the Transcript**
182 |
183 | ```python
184 | # words with timestamps
185 | text_json = video.get_transcript()
186 | text = video.get_transcript_text()
187 | print(text)
188 | ```
189 |
190 | **Add Subtitles to a video**
191 |
192 | It returns a new stream instantly with subtitles added to the video.
193 |
194 | ```python
195 | new_stream = video.add_subtitle()
196 | play_stream(new_stream)
197 | ```
198 |
199 | **Get Thumbnail of a Video:**
200 |
201 | `video.generate_thumbnail()`: Returns a thumbnail image of video.
202 |
203 | **Delete a video:**
204 |
205 | `video.delete()`: Deletes the video.
206 |
207 | Checkout more examples and tutorials 👉 [Build with VideoDB](https://docs.videodb.io/build-with-videodb-35) to explore what you can build with `VideoDB`.
208 |
209 | ---
210 |
211 |
212 |
213 | ## Roadmap
214 |
215 | - Adding More Indexes : `Face`, `Scene`, `Security`, `Events`, and `Sports`
216 | - Give prompt support to generate thumbnails using GenAI.
217 | - Give prompt support to access content.
218 | - Give prompt support to edit videos.
219 | - See the [open issues](https://github.com/video-db/videodb-python/issues) for a list of proposed features (and known issues).
220 |
221 | ---
222 |
223 |
224 |
225 | ## Contributing
226 |
227 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
228 |
229 | 1. Fork the Project
230 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
231 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
232 | 4. Push to the Branch (`git push origin feature/AmazingFeature`)
233 | 5. Open a Pull Request
234 |
235 | ---
236 |
237 |
238 |
239 |
240 | [pypi-shield]: https://img.shields.io/pypi/v/videodb?style=for-the-badge
241 | [pypi-url]: https://pypi.org/project/videodb/
242 | [python-shield]: https://img.shields.io/pypi/pyversions/videodb?style=for-the-badge
243 | [stars-shield]: https://img.shields.io/github/stars/video-db/videodb-python.svg?style=for-the-badge
244 | [stars-url]: https://github.com/video-db/videodb-python/stargazers
245 | [issues-shield]: https://img.shields.io/github/issues/video-db/videodb-python.svg?style=for-the-badge
246 | [issues-url]: https://github.com/video-db/videodb-python/issues
247 | [website-shield]: https://img.shields.io/website?url=https%3A%2F%2Fvideodb.io%2F&style=for-the-badge&label=videodb.io
248 | [website-url]: https://videodb.io/
249 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | ruff==0.1.7
2 | pytest==7.4.3
3 | twine==5.1.1
4 | wheel==0.42.0
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests==2.31.0
2 | backoff==2.2.1
3 | tqdm==4.66.1
4 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # package setup
2 | import os
3 | from setuptools import setup, find_packages
4 |
5 | ROOT = os.path.dirname(os.path.abspath(__file__))
6 |
7 |
8 | # Read in the package version per recommendations from:
9 | # https://packaging.python.org/guides/single-sourcing-package-version/
10 |
11 | about_path = os.path.join(ROOT, "videodb", "__about__.py")
12 | about = {}
13 | with open(about_path) as fp:
14 | exec(fp.read(), about)
15 |
16 |
17 | # read the contents of README file
18 | long_description = open(os.path.join(ROOT, "README.md"), "r", encoding="utf-8").read()
19 |
20 |
21 | setup(
22 | name=about["__title__"],
23 | version=about["__version__"],
24 | author=about["__author__"],
25 | author_email=about["__email__"],
26 | license=about["__license__"],
27 | description="VideoDB Python SDK",
28 | long_description=long_description,
29 | long_description_content_type="text/markdown",
30 | url=about["__url__"],
31 | packages=find_packages(exclude=["tests", "tests.*"]),
32 | python_requires=">=3.8",
33 | install_requires=[
34 | "requests>=2.25.1",
35 | "backoff>=2.2.1",
36 | "tqdm>=4.66.1",
37 | ],
38 | classifiers=[
39 | "Intended Audience :: Developers",
40 | "Programming Language :: Python :: 3",
41 | "Programming Language :: Python :: 3.8",
42 | "Programming Language :: Python :: 3.9",
43 | "Programming Language :: Python :: 3.10",
44 | "Programming Language :: Python :: 3.11",
45 | "Programming Language :: Python :: 3.12",
46 | "License :: OSI Approved :: Apache Software License",
47 | ],
48 | )
49 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/video-db/videodb-python/48ee29d8aad107c6f8e81347b9178b8e32e8ffd1/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_exceptions.py:
--------------------------------------------------------------------------------
1 | from videodb.exceptions import (
2 | VideodbError,
3 | AuthenticationError,
4 | InvalidRequestError,
5 | SearchError,
6 | )
7 |
8 |
9 | def test_videodb_error():
10 | try:
11 | raise VideodbError("An error occurred", cause="Something")
12 |
13 | except VideodbError as e:
14 | assert str(e) == "An error occurred caused by Something"
15 | assert e.cause == "Something"
16 |
17 |
18 | def test_authentication_error():
19 | try:
20 | raise AuthenticationError(
21 | "An error occurred with authentication", response="Something"
22 | )
23 |
24 | except AuthenticationError as e:
25 | print(e)
26 | assert str(e) == "An error occurred with authentication "
27 | assert e.response == "Something"
28 |
29 |
30 | def test_invalid_request_error():
31 | try:
32 | raise InvalidRequestError(
33 | "An error occurred with request", response="Something"
34 | )
35 |
36 | except InvalidRequestError as e:
37 | assert str(e) == "An error occurred with request "
38 | assert e.response == "Something"
39 |
40 |
41 | def test_search_error():
42 | try:
43 | raise SearchError("An error occurred with search")
44 |
45 | except SearchError as e:
46 | assert str(e) == "An error occurred with search "
47 |
--------------------------------------------------------------------------------
/videodb/__about__.py:
--------------------------------------------------------------------------------
1 | """ About information for videodb sdk"""
2 |
3 |
4 | __version__ = "0.2.14"
5 | __title__ = "videodb"
6 | __author__ = "videodb"
7 | __email__ = "contact@videodb.io"
8 | __url__ = "https://github.com/video-db/videodb-python"
9 | __license__ = "Apache License 2.0"
10 |
--------------------------------------------------------------------------------
/videodb/__init__.py:
--------------------------------------------------------------------------------
1 | """Videodb API client library"""
2 |
3 | import os
4 | import logging
5 |
6 | from typing import Optional
7 | from videodb._utils._video import play_stream
8 | from videodb._constants import (
9 | VIDEO_DB_API,
10 | IndexType,
11 | SceneExtractionType,
12 | MediaType,
13 | SearchType,
14 | Segmenter,
15 | SubtitleAlignment,
16 | SubtitleBorderStyle,
17 | SubtitleStyle,
18 | TextStyle,
19 | )
20 | from videodb.client import Connection
21 | from videodb.exceptions import (
22 | VideodbError,
23 | AuthenticationError,
24 | InvalidRequestError,
25 | SearchError,
26 | )
27 |
28 | logger: logging.Logger = logging.getLogger("videodb")
29 |
30 |
31 | __all__ = [
32 | "VideodbError",
33 | "AuthenticationError",
34 | "InvalidRequestError",
35 | "IndexType",
36 | "SearchError",
37 | "play_stream",
38 | "MediaType",
39 | "SearchType",
40 | "SubtitleAlignment",
41 | "SubtitleBorderStyle",
42 | "SubtitleStyle",
43 | "TextStyle",
44 | "SceneExtractionType",
45 | "Segmenter",
46 | ]
47 |
48 |
49 | def connect(
50 | api_key: str = None,
51 | base_url: Optional[str] = VIDEO_DB_API,
52 | log_level: Optional[int] = logging.INFO,
53 | ) -> Connection:
54 | """A client for interacting with a videodb via REST API
55 |
56 | :param str api_key: The api key to use for authentication
57 | :param str base_url: (optional) The base url to use for the api
58 | :param int log_level: (optional) The log level to use for the logger
59 | :return: A connection object
60 | :rtype: videodb.client.Connection
61 | """
62 |
63 | logger.setLevel(log_level)
64 | if api_key is None:
65 | api_key = os.environ.get("VIDEO_DB_API_KEY")
66 | if api_key is None:
67 | raise AuthenticationError(
68 | "No API key provided. Set an API key either as an environment variable (VIDEO_DB_API_KEY) or pass it as an argument."
69 | )
70 |
71 | return Connection(api_key, base_url)
72 |
--------------------------------------------------------------------------------
/videodb/_constants.py:
--------------------------------------------------------------------------------
1 | """Constants used in the videodb package."""
2 |
3 | from typing import Union
4 | from dataclasses import dataclass
5 |
6 | VIDEO_DB_API: str = "https://api.videodb.io"
7 |
8 |
9 | class MediaType:
10 | video = "video"
11 | audio = "audio"
12 | image = "image"
13 |
14 |
15 | class SearchType:
16 | semantic = "semantic"
17 | keyword = "keyword"
18 | scene = "scene"
19 | llm = "llm"
20 |
21 |
22 | class IndexType:
23 | spoken_word = "spoken_word"
24 | scene = "scene"
25 |
26 |
27 | class SceneExtractionType:
28 | shot_based = "shot"
29 | time_based = "time"
30 |
31 |
32 | class Workflows:
33 | add_subtitles = "add_subtitles"
34 |
35 |
36 | class SemanticSearchDefaultValues:
37 | result_threshold = 5
38 | score_threshold = 0.2
39 |
40 |
41 | class Segmenter:
42 | time = "time"
43 | word = "word"
44 | sentence = "sentence"
45 |
46 |
47 | class ApiPath:
48 | collection = "collection"
49 | upload = "upload"
50 | video = "video"
51 | audio = "audio"
52 | image = "image"
53 | stream = "stream"
54 | thumbnail = "thumbnail"
55 | thumbnails = "thumbnails"
56 | upload_url = "upload_url"
57 | transcription = "transcription"
58 | index = "index"
59 | search = "search"
60 | compile = "compile"
61 | workflow = "workflow"
62 | timeline = "timeline"
63 | delete = "delete"
64 | billing = "billing"
65 | usage = "usage"
66 | invoices = "invoices"
67 | scenes = "scenes"
68 | scene = "scene"
69 | frame = "frame"
70 | describe = "describe"
71 | storage = "storage"
72 | download = "download"
73 | title = "title"
74 | rtstream = "rtstream"
75 | status = "status"
76 | event = "event"
77 | alert = "alert"
78 | generate_url = "generate_url"
79 | generate = "generate"
80 | web = "web"
81 | translate = "translate"
82 | dub = "dub"
83 |
84 |
85 | class Status:
86 | processing = "processing"
87 | in_progress = "in progress"
88 |
89 |
90 | class HttpClientDefaultValues:
91 | max_retries = 1
92 | timeout = 30
93 | backoff_factor = 0.1
94 | status_forcelist = [502, 503, 504]
95 |
96 |
97 | class MaxSupported:
98 | fade_duration = 5
99 |
100 |
101 | class SubtitleBorderStyle:
102 | no_border = 1
103 | opaque_box = 3
104 | outline = 4
105 |
106 |
107 | class SubtitleAlignment:
108 | bottom_left = 1
109 | bottom_center = 2
110 | bottom_right = 3
111 | middle_left = 9
112 | middle_center = 10
113 | middle_right = 11
114 | top_left = 5
115 | top_center = 6
116 | top_right = 7
117 |
118 |
119 | @dataclass
120 | class SubtitleStyle:
121 | font_name: str = "Arial"
122 | font_size: float = 18
123 | primary_colour: str = "&H00FFFFFF" # white
124 | secondary_colour: str = "&H000000FF" # blue
125 | outline_colour: str = "&H00000000" # black
126 | back_colour: str = "&H00000000" # black
127 | bold: bool = False
128 | italic: bool = False
129 | underline: bool = False
130 | strike_out: bool = False
131 | scale_x: float = 1.0
132 | scale_y: float = 1.0
133 | spacing: float = 0
134 | angle: float = 0
135 | border_style: int = SubtitleBorderStyle.outline
136 | outline: float = 1.0
137 | shadow: float = 0.0
138 | alignment: int = SubtitleAlignment.bottom_center
139 | margin_l: int = 10
140 | margin_r: int = 10
141 | margin_v: int = 10
142 |
143 |
144 | @dataclass
145 | class TextStyle:
146 | fontsize: int = 24
147 | fontcolor: str = "black"
148 | fontcolor_expr: str = ""
149 | alpha: float = 1.0
150 | font: str = "Sans"
151 | box: bool = True
152 | boxcolor: str = "white"
153 | boxborderw: str = "10"
154 | boxw: int = 0
155 | boxh: int = 0
156 | line_spacing: int = 0
157 | text_align: str = "T"
158 | y_align: str = "text"
159 | borderw: int = 0
160 | bordercolor: str = "black"
161 | expansion: str = "normal"
162 | basetime: int = 0
163 | fix_bounds: bool = False
164 | text_shaping: bool = True
165 | shadowcolor: str = "black"
166 | shadowx: int = 0
167 | shadowy: int = 0
168 | tabsize: int = 4
169 | x: Union[str, int] = "(main_w-text_w)/2"
170 | y: Union[str, int] = "(main_h-text_h)/2"
171 |
--------------------------------------------------------------------------------
/videodb/_upload.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 | from typing import Optional
4 | from requests import HTTPError
5 |
6 |
7 | from videodb._constants import (
8 | ApiPath,
9 | )
10 |
11 | from videodb.exceptions import (
12 | VideodbError,
13 | )
14 |
15 |
16 | def upload(
17 | _connection,
18 | file_path: str = None,
19 | url: str = None,
20 | media_type: Optional[str] = None,
21 | name: Optional[str] = None,
22 | description: Optional[str] = None,
23 | callback_url: Optional[str] = None,
24 | ) -> dict:
25 | if not file_path and not url:
26 | raise VideodbError("Either file_path or url is required")
27 | if file_path and url:
28 | raise VideodbError("Only one of file_path or url is allowed")
29 |
30 | if file_path:
31 | try:
32 | name = file_path.split("/")[-1].split(".")[0] if not name else name
33 | upload_url_data = _connection.get(
34 | path=f"{ApiPath.collection}/{_connection.collection_id}/{ApiPath.upload_url}",
35 | params={"name": name},
36 | )
37 | upload_url = upload_url_data.get("upload_url")
38 | with open(file_path, "rb") as file:
39 | files = {"file": (name, file)}
40 | response = requests.post(upload_url, files=files)
41 | response.raise_for_status()
42 | url = upload_url
43 |
44 | except FileNotFoundError as e:
45 | raise VideodbError("File not found", cause=e)
46 |
47 | except HTTPError as e:
48 | raise VideodbError("Error while uploading file", cause=e)
49 |
50 | upload_data = _connection.post(
51 | path=f"{ApiPath.collection}/{_connection.collection_id}/{ApiPath.upload}",
52 | data={
53 | "url": url,
54 | "name": name,
55 | "description": description,
56 | "callback_url": callback_url,
57 | "media_type": media_type,
58 | },
59 | )
60 | return upload_data
61 |
--------------------------------------------------------------------------------
/videodb/_utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/video-db/videodb-python/48ee29d8aad107c6f8e81347b9178b8e32e8ffd1/videodb/_utils/__init__.py
--------------------------------------------------------------------------------
/videodb/_utils/_http_client.py:
--------------------------------------------------------------------------------
1 | """Http client Module."""
2 |
3 | import logging
4 | import requests
5 | import backoff
6 |
7 | from tqdm import tqdm
8 | from typing import (
9 | Callable,
10 | Optional,
11 | )
12 | from requests.adapters import HTTPAdapter
13 | from requests.packages.urllib3.util.retry import Retry
14 |
15 | from videodb._constants import (
16 | HttpClientDefaultValues,
17 | Status,
18 | )
19 | from videodb.exceptions import (
20 | AuthenticationError,
21 | InvalidRequestError,
22 | RequestTimeoutError,
23 | )
24 |
25 | logger = logging.getLogger(__name__)
26 |
27 |
28 | class HttpClient:
29 | """Http client for making requests"""
30 |
31 | def __init__(
32 | self,
33 | api_key: str,
34 | base_url: str,
35 | version: str,
36 | max_retries: Optional[int] = HttpClientDefaultValues.max_retries,
37 | ) -> None:
38 | """Create a new http client instance
39 |
40 | :param str api_key: The api key to use for authentication
41 | :param str base_url: The base url to use for the api
42 | :param int max_retries: (optional) The maximum number of retries to make for a request
43 | """
44 | self.session = requests.Session()
45 |
46 | retries = Retry(
47 | total=max_retries,
48 | backoff_factor=HttpClientDefaultValues.backoff_factor,
49 | status_forcelist=HttpClientDefaultValues.status_forcelist,
50 | )
51 | adapter = HTTPAdapter(max_retries=retries)
52 | self.session.mount("http://", adapter)
53 | self.session.mount("https://", adapter)
54 | self.version = version
55 | self.session.headers.update(
56 | {
57 | "x-access-token": api_key,
58 | "x-videodb-client": f"videodb-python/{self.version}",
59 | "Content-Type": "application/json",
60 | }
61 | )
62 | self.base_url = base_url
63 | self.show_progress = False
64 | self.progress_bar = None
65 | logger.debug(f"Initialized http client with base url: {self.base_url}")
66 |
67 | def _make_request(
68 | self,
69 | method: Callable[..., requests.Response],
70 | path: str,
71 | base_url: Optional[str] = None,
72 | headers: Optional[dict] = None,
73 | **kwargs,
74 | ):
75 | """Make a request to the api
76 |
77 | :param Callable method: The method to use for the request
78 | :param str path: The path to make the request to
79 | :param str base_url: (optional) The base url to use for the request
80 | :param dict headers: (optional) The headers to use for the request
81 | :param kwargs: The keyword arguments to pass to the request method
82 | :return: json response from the request
83 | """
84 | try:
85 | url = f"{base_url or self.base_url}/{path}"
86 | timeout = kwargs.pop("timeout", HttpClientDefaultValues.timeout)
87 | request_headers = {**self.session.headers, **(headers or {})}
88 | response = method(url, headers=request_headers, timeout=timeout, **kwargs)
89 | response.raise_for_status()
90 | return self._parse_response(response)
91 |
92 | except requests.exceptions.RequestException as e:
93 | self._handle_request_error(e)
94 |
95 | def _handle_request_error(self, e: requests.exceptions.RequestException) -> None:
96 | """Handle request errors"""
97 | self.show_progress = False
98 | if isinstance(e, requests.exceptions.HTTPError):
99 | try:
100 | error_message = e.response.json().get("message", "Unknown error")
101 | except ValueError:
102 | error_message = e.response.text
103 |
104 | if e.response.status_code == 401:
105 | raise AuthenticationError(
106 | f"Error: {error_message}", e.response
107 | ) from None
108 | else:
109 | raise InvalidRequestError(
110 | f"Invalid request: {error_message}", e.response
111 | ) from None
112 |
113 | elif isinstance(e, requests.exceptions.RetryError):
114 | raise InvalidRequestError(
115 | "Invalid request: Max retries exceeded", e.response
116 | ) from None
117 |
118 | elif isinstance(e, requests.exceptions.Timeout):
119 | raise RequestTimeoutError(
120 | "Timeout error: Request timed out", e.response
121 | ) from None
122 |
123 | elif isinstance(e, requests.exceptions.ConnectionError):
124 | raise InvalidRequestError(
125 | "Invalid request: Connection error", e.response
126 | ) from None
127 |
128 | else:
129 | raise InvalidRequestError(
130 | f"Invalid request: {str(e)}", e.response
131 | ) from None
132 |
133 | @backoff.on_exception(
134 | backoff.constant, Exception, max_time=500, interval=5, logger=None, jitter=None
135 | )
136 | def _get_output(self, url: str):
137 | """Get the output from an async request"""
138 | response_json = self.session.get(url).json()
139 | if (
140 | response_json.get("status") == Status.in_progress
141 | or response_json.get("status") == Status.processing
142 | ):
143 | percentage = response_json.get("data", {}).get("percentage")
144 | if percentage and self.show_progress and self.progress_bar:
145 | self.progress_bar.n = int(percentage)
146 | self.progress_bar.update(0)
147 |
148 | logger.debug("Waiting for processing to complete")
149 | raise Exception("Stuck on processing status") from None
150 | if self.show_progress and self.progress_bar:
151 | self.progress_bar.n = 100
152 | self.progress_bar.update(0)
153 | self.progress_bar.close()
154 | self.progress_bar = None
155 | self.show_progress = False
156 | return response_json.get("response") or response_json
157 |
158 | def _parse_response(self, response: requests.Response):
159 | """Parse the response from the api"""
160 | try:
161 | response_json = response.json()
162 | if (
163 | response_json.get("status") == Status.processing
164 | and response_json.get("request_type", "sync") == "async"
165 | ):
166 | return None
167 | elif (
168 | response_json.get("status") == Status.processing
169 | and response_json.get("request_type", "sync") == "sync"
170 | ):
171 | if self.show_progress:
172 | self.progress_bar = tqdm(
173 | total=100,
174 | position=0,
175 | leave=True,
176 | bar_format="{l_bar}{bar:100}{r_bar}{bar:-100b}",
177 | )
178 | response_json = self._get_output(
179 | response_json.get("data", {}).get("output_url")
180 | )
181 | if response_json.get("success"):
182 | return response_json.get("data")
183 | else:
184 | raise InvalidRequestError(
185 | f"Invalid request: {response_json.get('message')}", response
186 | ) from None
187 |
188 | elif response_json.get("success"):
189 | return response_json.get("data")
190 |
191 | else:
192 | raise InvalidRequestError(
193 | f"Invalid request: {response_json.get('message')}", response
194 | ) from None
195 |
196 | except ValueError:
197 | raise InvalidRequestError(
198 | f"Invalid request: {response.text}", response
199 | ) from None
200 |
201 | def get(
202 | self, path: str, show_progress: Optional[bool] = False, **kwargs
203 | ) -> requests.Response:
204 | """Make a get request"""
205 | self.show_progress = show_progress
206 | return self._make_request(method=self.session.get, path=path, **kwargs)
207 |
208 | def post(
209 | self, path: str, data=None, show_progress: Optional[bool] = False, **kwargs
210 | ) -> requests.Response:
211 | """Make a post request"""
212 | self.show_progress = show_progress
213 | return self._make_request(self.session.post, path, json=data, **kwargs)
214 |
215 | def put(self, path: str, data=None, **kwargs) -> requests.Response:
216 | """Make a put request"""
217 | return self._make_request(self.session.put, path, json=data, **kwargs)
218 |
219 | def delete(self, path: str, **kwargs) -> requests.Response:
220 | """Make a delete request"""
221 | return self._make_request(self.session.delete, path, **kwargs)
222 |
223 | def patch(self, path: str, data=None, **kwargs) -> requests.Response:
224 | """Make a patch request"""
225 | return self._make_request(self.session.patch, path, json=data, **kwargs)
226 |
--------------------------------------------------------------------------------
/videodb/_utils/_video.py:
--------------------------------------------------------------------------------
1 | import webbrowser as web
2 | PLAYER_URL: str = "https://console.videodb.io/player"
3 |
4 |
5 | def play_stream(url: str):
6 | """Play a stream url in the browser/ notebook
7 |
8 | :param str url: The url of the stream
9 | :return: The player url if the stream is opened in the browser or the iframe if the stream is opened in the notebook
10 | """
11 | player = f"{PLAYER_URL}?url={url}"
12 | opend = web.open(player)
13 | if not opend:
14 | try:
15 | from IPython.display import IFrame
16 |
17 | player_width = 800
18 | player_height = 400
19 | return IFrame(player, player_width, player_height)
20 | except ImportError:
21 | return player
22 | return player
23 |
--------------------------------------------------------------------------------
/videodb/asset.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import logging
3 | import uuid
4 |
5 | from typing import Optional, Union
6 |
7 | from videodb._constants import MaxSupported, TextStyle
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | def validate_max_supported(
13 | duration: Union[int, float], max_duration: Union[int, float], attribute: str = ""
14 | ) -> Union[int, float, None]:
15 | if duration is None:
16 | return 0
17 | if duration is not None and max_duration is not None and duration > max_duration:
18 | logger.warning(
19 | f"{attribute}: {duration} is greater than max supported: {max_duration}"
20 | )
21 | return duration
22 |
23 |
24 | class MediaAsset:
25 | def __init__(self, asset_id: str) -> None:
26 | self.asset_id: str = asset_id
27 |
28 | def to_json(self) -> dict:
29 | return self.__dict__
30 |
31 |
32 | class VideoAsset(MediaAsset):
33 | def __init__(
34 | self,
35 | asset_id: str,
36 | start: Optional[float] = 0,
37 | end: Optional[float] = None,
38 | ) -> None:
39 | super().__init__(asset_id)
40 | self.start: int = start
41 | self.end: Union[int, None] = end
42 |
43 | def to_json(self) -> dict:
44 | return copy.deepcopy(self.__dict__)
45 |
46 | def __repr__(self) -> str:
47 | return (
48 | f"VideoAsset("
49 | f"asset_id={self.asset_id}, "
50 | f"start={self.start}, "
51 | f"end={self.end})"
52 | )
53 |
54 |
55 | class AudioAsset(MediaAsset):
56 | def __init__(
57 | self,
58 | asset_id: str,
59 | start: Optional[float] = 0,
60 | end: Optional[float] = None,
61 | disable_other_tracks: Optional[bool] = True,
62 | fade_in_duration: Optional[Union[int, float]] = 0,
63 | fade_out_duration: Optional[Union[int, float]] = 0,
64 | ):
65 | super().__init__(asset_id)
66 | self.start: int = start
67 | self.end: Union[int, None] = end
68 | self.disable_other_tracks: bool = disable_other_tracks
69 | self.fade_in_duration: Union[int, float] = validate_max_supported(
70 | fade_in_duration, MaxSupported.fade_duration, "fade_in_duration"
71 | )
72 | self.fade_out_duration: Union[int, float] = validate_max_supported(
73 | fade_out_duration, MaxSupported.fade_duration, "fade_out_duration"
74 | )
75 |
76 | def to_json(self) -> dict:
77 | return copy.deepcopy(self.__dict__)
78 |
79 | def __repr__(self) -> str:
80 | return (
81 | f"AudioAsset("
82 | f"asset_id={self.asset_id}, "
83 | f"start={self.start}, "
84 | f"end={self.end}, "
85 | f"disable_other_tracks={self.disable_other_tracks}, "
86 | f"fade_in_duration={self.fade_in_duration}, "
87 | f"fade_out_duration={self.fade_out_duration})"
88 | )
89 |
90 |
91 | class ImageAsset(MediaAsset):
92 | def __init__(
93 | self,
94 | asset_id: str,
95 | width: Union[int, str] = 100,
96 | height: Union[int, str] = 100,
97 | x: Union[int, str] = 80,
98 | y: Union[int, str] = 20,
99 | duration: Optional[int] = None,
100 | ) -> None:
101 | super().__init__(asset_id)
102 | self.width = width
103 | self.height = height
104 | self.x = x
105 | self.y = y
106 | self.duration = duration
107 |
108 | def to_json(self) -> dict:
109 | return copy.deepcopy(self.__dict__)
110 |
111 | def __repr__(self) -> str:
112 | return (
113 | f"ImageAsset("
114 | f"asset_id={self.asset_id}, "
115 | f"width={self.width}, "
116 | f"height={self.height}, "
117 | f"x={self.x}, "
118 | f"y={self.y}, "
119 | f"duration={self.duration})"
120 | )
121 |
122 |
123 | class TextAsset(MediaAsset):
124 | def __init__(
125 | self,
126 | text: str,
127 | duration: Optional[int] = None,
128 | style: TextStyle = TextStyle(),
129 | ) -> None:
130 | super().__init__(f"txt-{str(uuid.uuid4())}")
131 | self.text = text
132 | self.duration = duration
133 | self.style: TextStyle = style
134 |
135 | def to_json(self) -> dict:
136 | return {
137 | "text": copy.deepcopy(self.text),
138 | "asset_id": copy.deepcopy(self.asset_id),
139 | "duration": copy.deepcopy(self.duration),
140 | "style": copy.deepcopy(self.style.__dict__),
141 | }
142 |
143 | def __repr__(self) -> str:
144 | return (
145 | f"TextAsset("
146 | f"text={self.text}, "
147 | f"asset_id={self.asset_id}, "
148 | f"duration={self.duration}, "
149 | f"style={self.style})"
150 | )
151 |
--------------------------------------------------------------------------------
/videodb/audio.py:
--------------------------------------------------------------------------------
1 | from videodb._constants import (
2 | ApiPath,
3 | )
4 |
5 |
6 | class Audio:
7 | """Audio class to interact with the Audio
8 |
9 | :ivar str id: Unique identifier for the audio
10 | :ivar str collection_id: ID of the collection this audio belongs to
11 | :ivar str name: Name of the audio file
12 | :ivar float length: Duration of the audio in seconds
13 | """
14 |
15 | def __init__(
16 | self, _connection, id: str, collection_id: str, **kwargs
17 | ) -> None:
18 | self._connection = _connection
19 | self.id = id
20 | self.collection_id = collection_id
21 | self.name = kwargs.get("name", None)
22 | self.length = kwargs.get("length", None)
23 |
24 | def __repr__(self) -> str:
25 | return (
26 | f"Audio("
27 | f"id={self.id}, "
28 | f"collection_id={self.collection_id}, "
29 | f"name={self.name}, "
30 | f"length={self.length})"
31 | )
32 |
33 | def generate_url(self) -> str:
34 | """Generate the signed url of the audio.
35 |
36 | :raises InvalidRequestError: If the get_url fails
37 | :return: The signed url of the audio
38 | :rtype: str
39 | """
40 | url_data = self._connection.post(
41 | path=f"{ApiPath.audio}/{self.id}/{ApiPath.generate_url}",
42 | params={"collection_id": self.collection_id},
43 | )
44 | return url_data.get("signed_url", None)
45 |
46 | def delete(self) -> None:
47 | """Delete the audio.
48 |
49 | :raises InvalidRequestError: If the delete fails
50 | :return: None if the delete is successful
51 | :rtype: None
52 | """
53 | self._connection.delete(f"{ApiPath.audio}/{self.id}")
54 |
--------------------------------------------------------------------------------
/videodb/client.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from typing import (
4 | Optional,
5 | Union,
6 | List,
7 | )
8 | from videodb.__about__ import __version__
9 | from videodb._constants import (
10 | ApiPath,
11 | )
12 |
13 | from videodb.collection import Collection
14 | from videodb._utils._http_client import HttpClient
15 | from videodb.video import Video
16 | from videodb.audio import Audio
17 | from videodb.image import Image
18 |
19 | from videodb._upload import (
20 | upload,
21 | )
22 |
23 | logger = logging.getLogger(__name__)
24 |
25 |
26 | class Connection(HttpClient):
27 | """Connection class to interact with the VideoDB"""
28 |
29 | def __init__(self, api_key: str, base_url: str) -> "Connection":
30 | """Initializes a new instance of the Connection class with specified API credentials.
31 |
32 | Note: Users should not initialize this class directly.
33 | Instead use :meth:`videodb.connect() `
34 |
35 | :param str api_key: API key for authentication
36 | :param str base_url: Base URL of the VideoDB API
37 | :raise ValueError: If the API key is not provided
38 | :return: :class:`Connection ` object, to interact with the VideoDB
39 | :rtype: :class:`videodb.client.Connection`
40 | """
41 | self.api_key = api_key
42 | self.base_url = base_url
43 | self.collection_id = "default"
44 | super().__init__(api_key=api_key, base_url=base_url, version=__version__)
45 |
46 | def get_collection(self, collection_id: Optional[str] = "default") -> Collection:
47 | """Get a collection object by its ID.
48 |
49 | :param str collection_id: ID of the collection (optional, default: "default")
50 | :return: :class:`Collection ` object
51 | :rtype: :class:`videodb.collection.Collection`
52 | """
53 | collection_data = self.get(path=f"{ApiPath.collection}/{collection_id}")
54 | self.collection_id = collection_data.get("id", "default")
55 | return Collection(
56 | self,
57 | self.collection_id,
58 | collection_data.get("name"),
59 | collection_data.get("description"),
60 | collection_data.get("is_public", False),
61 | )
62 |
63 | def get_collections(self) -> List[Collection]:
64 | """Get a list of all collections.
65 |
66 | :return: List of :class:`Collection ` objects
67 | :rtype: list[:class:`videodb.collection.Collection`]
68 | """
69 | collections_data = self.get(path=ApiPath.collection)
70 | return [
71 | Collection(
72 | self,
73 | collection.get("id"),
74 | collection.get("name"),
75 | collection.get("description"),
76 | collection.get("is_public", False),
77 | )
78 | for collection in collections_data.get("collections")
79 | ]
80 |
81 | def create_collection(
82 | self, name: str, description: str, is_public: bool = False
83 | ) -> Collection:
84 | """Create a new collection.
85 |
86 | :param str name: Name of the collection
87 | :param str description: Description of the collection
88 | :param bool is_public: Make collection public (optional, default: False)
89 | :return: :class:`Collection ` object
90 | :rtype: :class:`videodb.collection.Collection`
91 | """
92 | collection_data = self.post(
93 | path=ApiPath.collection,
94 | data={
95 | "name": name,
96 | "description": description,
97 | "is_public": is_public,
98 | },
99 | )
100 | self.collection_id = collection_data.get("id", "default")
101 | return Collection(
102 | self,
103 | collection_data.get("id"),
104 | collection_data.get("name"),
105 | collection_data.get("description"),
106 | collection_data.get("is_public", False),
107 | )
108 |
109 | def update_collection(self, id: str, name: str, description: str) -> Collection:
110 | """Update an existing collection.
111 |
112 | :param str id: ID of the collection
113 | :param str name: Name of the collection
114 | :param str description: Description of the collection
115 | :return: :class:`Collection ` object
116 | :rtype: :class:`videodb.collection.Collection`
117 | """
118 | collection_data = self.patch(
119 | path=f"{ApiPath.collection}/{id}",
120 | data={
121 | "name": name,
122 | "description": description,
123 | },
124 | )
125 | self.collection_id = collection_data.get("id", "default")
126 | return Collection(
127 | self,
128 | collection_data.get("id"),
129 | collection_data.get("name"),
130 | collection_data.get("description"),
131 | collection_data.get("is_public", False),
132 | )
133 |
134 | def check_usage(self) -> dict:
135 | """Check the usage.
136 |
137 | :return: Usage data
138 | :rtype: dict
139 | """
140 | return self.get(path=f"{ApiPath.billing}/{ApiPath.usage}")
141 |
142 | def get_invoices(self) -> List[dict]:
143 | """Get a list of all invoices.
144 |
145 | :return: List of invoices
146 | :rtype: list[dict]
147 | """
148 | return self.get(path=f"{ApiPath.billing}/{ApiPath.invoices}")
149 |
150 | def create_event(self, event_prompt: str, label: str):
151 | """Create an rtstream event.
152 |
153 | :param str event_prompt: Prompt for the event
154 | :param str label: Label for the event
155 | :return: Event ID
156 | :rtype: str
157 | """
158 | event_data = self.post(
159 | f"{ApiPath.rtstream}/{ApiPath.event}",
160 | data={"event_prompt": event_prompt, "label": label},
161 | )
162 |
163 | return event_data.get("event_id")
164 |
165 | def list_events(self):
166 | """List all rtstream events.
167 |
168 | :return: List of events
169 | :rtype: list[dict]
170 | """
171 | event_data = self.get(f"{ApiPath.rtstream}/{ApiPath.event}")
172 | return event_data.get("events", [])
173 |
174 | def download(self, stream_link: str, name: str) -> dict:
175 | """Download a file from a stream link.
176 |
177 | :param stream_link: URL of the stream to download
178 | :param name: Name to save the downloaded file as
179 | :return: Download response data
180 | :rtype: dict
181 | """
182 | return self.post(
183 | path=f"{ApiPath.download}",
184 | data={
185 | "stream_link": stream_link,
186 | "name": name,
187 | },
188 | )
189 |
190 | def youtube_search(
191 | self,
192 | query: str,
193 | result_threshold: Optional[int] = 10,
194 | duration: str = "medium",
195 | ) -> List[dict]:
196 | """Search for a query on YouTube.
197 |
198 | :param str query: Query to search for
199 | :param int result_threshold: Number of results to return (optional)
200 | :param str duration: Duration of the video (optional)
201 | :return: List of YouTube search results
202 | :rtype: List[dict]
203 | """
204 | search_data = self.post(
205 | path=f"{ApiPath.collection}/{self.collection_id}/{ApiPath.search}/{ApiPath.web}",
206 | data={
207 | "query": query,
208 | "result_threshold": result_threshold,
209 | "platform": "youtube",
210 | "duration": duration,
211 | },
212 | )
213 | return search_data.get("results")
214 |
215 | def upload(
216 | self,
217 | file_path: str = None,
218 | url: str = None,
219 | media_type: Optional[str] = None,
220 | name: Optional[str] = None,
221 | description: Optional[str] = None,
222 | callback_url: Optional[str] = None,
223 | ) -> Union[Video, Audio, Image, None]:
224 | """Upload a file.
225 |
226 | :param str file_path: Path to the file to upload (optional)
227 | :param str url: URL of the file to upload (optional)
228 | :param MediaType media_type: MediaType object (optional)
229 | :param str name: Name of the file (optional)
230 | :param str description: Description of the file (optional)
231 | :param str callback_url: URL to receive the callback (optional)
232 | :return: :class:`Video `, or :class:`Audio `, or :class:`Image ` object
233 | :rtype: Union[ :class:`videodb.video.Video`, :class:`videodb.audio.Audio`, :class:`videodb.image.Image`]
234 | """
235 | upload_data = upload(
236 | self,
237 | file_path,
238 | url,
239 | media_type,
240 | name,
241 | description,
242 | callback_url,
243 | )
244 | media_id = upload_data.get("id", "")
245 | if media_id.startswith("m-"):
246 | return Video(self, **upload_data)
247 | elif media_id.startswith("a-"):
248 | return Audio(self, **upload_data)
249 | elif media_id.startswith("img-"):
250 | return Image(self, **upload_data)
251 |
--------------------------------------------------------------------------------
/videodb/collection.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from typing import Optional, Union, List, Dict, Any, Literal
4 | from videodb._upload import (
5 | upload,
6 | )
7 | from videodb._constants import (
8 | ApiPath,
9 | IndexType,
10 | SearchType,
11 | )
12 | from videodb.video import Video
13 | from videodb.audio import Audio
14 | from videodb.image import Image
15 | from videodb.rtstream import RTStream
16 | from videodb.search import SearchFactory, SearchResult
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class Collection:
22 | """Collection class to interact with the Collection.
23 |
24 | Note: Users should not initialize this class directly.
25 | Instead use :meth:`Connection.get_collection() `
26 | """
27 |
28 | def __init__(
29 | self,
30 | _connection,
31 | id: str,
32 | name: str = None,
33 | description: str = None,
34 | is_public: bool = False,
35 | ):
36 | self._connection = _connection
37 | self.id = id
38 | self.name = name
39 | self.description = description
40 | self.is_public = is_public
41 |
42 | def __repr__(self) -> str:
43 | return (
44 | f"Collection("
45 | f"id={self.id}, "
46 | f"name={self.name}, "
47 | f"description={self.description}), "
48 | f"is_public={self.is_public})"
49 | )
50 |
51 | def delete(self) -> None:
52 | """Delete the collection
53 |
54 | :raises InvalidRequestError: If the delete fails
55 | :return: None if the delete is successful
56 | :rtype: None
57 | """
58 | self._connection.delete(path=f"{ApiPath.collection}/{self.id}")
59 |
60 | def get_videos(self) -> List[Video]:
61 | """Get all the videos in the collection.
62 |
63 | :return: List of :class:`Video ` objects
64 | :rtype: List[:class:`videodb.video.Video`]
65 | """
66 | videos_data = self._connection.get(
67 | path=f"{ApiPath.video}",
68 | params={"collection_id": self.id},
69 | )
70 | return [Video(self._connection, **video) for video in videos_data.get("videos")]
71 |
72 | def get_video(self, video_id: str) -> Video:
73 | """Get a video by its ID.
74 |
75 | :param str video_id: ID of the video
76 | :return: :class:`Video ` object
77 | :rtype: :class:`videodb.video.Video`
78 | """
79 | video_data = self._connection.get(
80 | path=f"{ApiPath.video}/{video_id}", params={"collection_id": self.id}
81 | )
82 | return Video(self._connection, **video_data)
83 |
84 | def delete_video(self, video_id: str) -> None:
85 | """Delete the video.
86 |
87 | :param str video_id: The id of the video to be deleted
88 | :raises InvalidRequestError: If the delete fails
89 | :return: None if the delete is successful
90 | :rtype: None
91 | """
92 | return self._connection.delete(
93 | path=f"{ApiPath.video}/{video_id}", params={"collection_id": self.id}
94 | )
95 |
96 | def get_audios(self) -> List[Audio]:
97 | """Get all the audios in the collection.
98 |
99 | :return: List of :class:`Audio ` objects
100 | :rtype: List[:class:`videodb.audio.Audio`]
101 | """
102 | audios_data = self._connection.get(
103 | path=f"{ApiPath.audio}",
104 | params={"collection_id": self.id},
105 | )
106 | return [Audio(self._connection, **audio) for audio in audios_data.get("audios")]
107 |
108 | def get_audio(self, audio_id: str) -> Audio:
109 | """Get an audio by its ID.
110 |
111 | :param str audio_id: ID of the audio
112 | :return: :class:`Audio ` object
113 | :rtype: :class:`videodb.audio.Audio`
114 | """
115 | audio_data = self._connection.get(
116 | path=f"{ApiPath.audio}/{audio_id}", params={"collection_id": self.id}
117 | )
118 | return Audio(self._connection, **audio_data)
119 |
120 | def delete_audio(self, audio_id: str) -> None:
121 | """Delete the audio.
122 |
123 | :param str audio_id: The id of the audio to be deleted
124 | :raises InvalidRequestError: If the delete fails
125 | :return: None if the delete is successful
126 | :rtype: None
127 | """
128 | return self._connection.delete(
129 | path=f"{ApiPath.audio}/{audio_id}", params={"collection_id": self.id}
130 | )
131 |
132 | def get_images(self) -> List[Image]:
133 | """Get all the images in the collection.
134 |
135 | :return: List of :class:`Image ` objects
136 | :rtype: List[:class:`videodb.image.Image`]
137 | """
138 | images_data = self._connection.get(
139 | path=f"{ApiPath.image}",
140 | params={"collection_id": self.id},
141 | )
142 | return [Image(self._connection, **image) for image in images_data.get("images")]
143 |
144 | def get_image(self, image_id: str) -> Image:
145 | """Get an image by its ID.
146 |
147 | :param str image_id: ID of the image
148 | :return: :class:`Image ` object
149 | :rtype: :class:`videodb.image.Image`
150 | """
151 | image_data = self._connection.get(
152 | path=f"{ApiPath.image}/{image_id}", params={"collection_id": self.id}
153 | )
154 | return Image(self._connection, **image_data)
155 |
156 | def delete_image(self, image_id: str) -> None:
157 | """Delete the image.
158 |
159 | :param str image_id: The id of the image to be deleted
160 | :raises InvalidRequestError: If the delete fails
161 | :return: None if the delete is successful
162 | :rtype: None
163 | """
164 | return self._connection.delete(
165 | path=f"{ApiPath.image}/{image_id}", params={"collection_id": self.id}
166 | )
167 |
168 | def connect_rtstream(
169 | self, url: str, name: str, sample_rate: int = None
170 | ) -> RTStream:
171 | """Connect to an rtstream.
172 |
173 | :param str url: URL of the rtstream
174 | :param str name: Name of the rtstream
175 | :param int sample_rate: Sample rate of the rtstream (optional)
176 | :return: :class:`RTStream ` object
177 | """
178 | rtstream_data = self._connection.post(
179 | path=f"{ApiPath.rtstream}",
180 | data={
181 | "collection_id": self.id,
182 | "url": url,
183 | "name": name,
184 | "sample_rate": sample_rate,
185 | },
186 | )
187 | return RTStream(self._connection, **rtstream_data)
188 |
189 | def get_rtstream(self, id: str) -> RTStream:
190 | """Get an rtstream by its ID.
191 |
192 | :param str id: ID of the rtstream
193 | :return: :class:`RTStream ` object
194 | :rtype: :class:`videodb.rtstream.RTStream`
195 | """
196 | rtstream_data = self._connection.get(
197 | path=f"{ApiPath.rtstream}/{id}",
198 | )
199 | return RTStream(self._connection, **rtstream_data)
200 |
201 | def list_rtstreams(self) -> List[RTStream]:
202 | """List all rtstreams in the collection.
203 |
204 | :return: List of :class:`RTStream ` objects
205 | :rtype: List[:class:`videodb.rtstream.RTStream`]
206 | """
207 | rtstreams_data = self._connection.get(
208 | path=f"{ApiPath.rtstream}",
209 | )
210 | return [
211 | RTStream(self._connection, **rtstream)
212 | for rtstream in rtstreams_data.get("results")
213 | ]
214 |
215 | def generate_image(
216 | self,
217 | prompt: str,
218 | aspect_ratio: Optional[Literal["1:1", "9:16", "16:9", "4:3", "3:4"]] = "1:1",
219 | callback_url: Optional[str] = None,
220 | ) -> Image:
221 | """Generate an image from a prompt.
222 |
223 | :param str prompt: Prompt for the image generation
224 | :param str aspect_ratio: Aspect ratio of the image (optional)
225 | :param str callback_url: URL to receive the callback (optional)
226 | :return: :class:`Image ` object
227 | :rtype: :class:`videodb.image.Image`
228 | """
229 | image_data = self._connection.post(
230 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.image}",
231 | data={
232 | "prompt": prompt,
233 | "aspect_ratio": aspect_ratio,
234 | "callback_url": callback_url,
235 | },
236 | )
237 | if image_data:
238 | return Image(self._connection, **image_data)
239 |
240 | def generate_music(
241 | self, prompt: str, duration: int = 5, callback_url: Optional[str] = None
242 | ) -> Audio:
243 | """Generate music from a prompt.
244 |
245 | :param str prompt: Prompt for the music generation
246 | :param int duration: Duration of the music in seconds
247 | :param str callback_url: URL to receive the callback (optional)
248 | :return: :class:`Audio ` object
249 | :rtype: :class:`videodb.audio.Audio`
250 | """
251 | audio_data = self._connection.post(
252 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.audio}",
253 | data={
254 | "prompt": prompt,
255 | "duration": duration,
256 | "audio_type": "music",
257 | "callback_url": callback_url,
258 | },
259 | )
260 | if audio_data:
261 | return Audio(self._connection, **audio_data)
262 |
263 | def generate_sound_effect(
264 | self,
265 | prompt: str,
266 | duration: int = 2,
267 | config: dict = {},
268 | callback_url: Optional[str] = None,
269 | ) -> Audio:
270 | """Generate sound effect from a prompt.
271 |
272 | :param str prompt: Prompt for the sound effect generation
273 | :param int duration: Duration of the sound effect in seconds
274 | :param dict config: Configuration for the sound effect generation
275 | :param str callback_url: URL to receive the callback (optional)
276 | :return: :class:`Audio ` object
277 | :rtype: :class:`videodb.audio.Audio`
278 | """
279 | audio_data = self._connection.post(
280 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.audio}",
281 | data={
282 | "prompt": prompt,
283 | "duration": duration,
284 | "audio_type": "sound_effect",
285 | "config": config,
286 | "callback_url": callback_url,
287 | },
288 | )
289 | if audio_data:
290 | return Audio(self._connection, **audio_data)
291 |
292 | def generate_voice(
293 | self,
294 | text: str,
295 | voice_name: str = "Default",
296 | config: dict = {},
297 | callback_url: Optional[str] = None,
298 | ) -> Audio:
299 | """Generate voice from text.
300 |
301 | :param str text: Text to convert to voice
302 | :param str voice_name: Name of the voice to use
303 | :param dict config: Configuration for the voice generation
304 | :param str callback_url: URL to receive the callback (optional)
305 | :return: :class:`Audio ` object
306 | :rtype: :class:`videodb.audio.Audio`
307 | """
308 | audio_data = self._connection.post(
309 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.audio}",
310 | data={
311 | "text": text,
312 | "audio_type": "voice",
313 | "voice_name": voice_name,
314 | "config": config,
315 | "callback_url": callback_url,
316 | },
317 | )
318 | if audio_data:
319 | return Audio(self._connection, **audio_data)
320 |
321 | def generate_video(
322 | self,
323 | prompt: str,
324 | duration: float = 5,
325 | callback_url: Optional[str] = None,
326 | ) -> Video:
327 | """
328 | Generate a video from the given text prompt.
329 |
330 | This method sends a request to generate a video using the provided prompt,
331 | duration. If a callback URL is provided, the generation result will be sent to that endpoint asynchronously.
332 |
333 | :param str prompt: Text prompt used as input for video generation.
334 |
335 | :param float duration:
336 | Duration of the generated video in seconds.
337 | Must be an **integer value** (not a float) and must be **between 5 and 8 inclusive**.
338 | A `ValueError` will be raised if the validation fails.
339 |
340 | :param str callback_url:
341 | Optional URL to receive a callback once video generation is complete.
342 |
343 | :return:
344 | A `Video` object containing the generated video metadata and access details.
345 |
346 | :rtype:
347 | :class:`videodb.video.Video`
348 | """
349 | video_data = self._connection.post(
350 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.video}",
351 | data={
352 | "prompt": prompt,
353 | "duration": duration,
354 | "callback_url": callback_url,
355 | },
356 | )
357 | if video_data:
358 | return Video(self._connection, **video_data)
359 |
360 | def dub_video(
361 | self, video_id: str, language_code: str, callback_url: Optional[str] = None
362 | ) -> Video:
363 | """Dub a video.
364 |
365 | :param str video_id: ID of the video to dub
366 | :param str language_code: Language code to dub the video to
367 | :param str callback_url: URL to receive the callback (optional)
368 | :return: :class:`Video ` object
369 | :rtype: :class:`videodb.video.Video`
370 | """
371 | dub_data = self._connection.post(
372 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.generate}/{ApiPath.video}/{ApiPath.dub}",
373 | data={
374 | "video_id": video_id,
375 | "language_code": language_code,
376 | "callback_url": callback_url,
377 | },
378 | )
379 | if dub_data:
380 | return Video(self._connection, **dub_data)
381 |
382 | def search(
383 | self,
384 | query: str,
385 | search_type: Optional[str] = SearchType.semantic,
386 | index_type: Optional[str] = IndexType.spoken_word,
387 | result_threshold: Optional[int] = None,
388 | score_threshold: Optional[float] = None,
389 | dynamic_score_percentage: Optional[float] = None,
390 | filter: List[Dict[str, Any]] = [],
391 | ) -> SearchResult:
392 | """Search for a query in the collection.
393 |
394 | :param str query: Query to search for
395 | :param SearchType search_type: Type of search to perform (optional)
396 | :param IndexType index_type: Type of index to search (optional)
397 | :param int result_threshold: Number of results to return (optional)
398 | :param float score_threshold: Threshold score for the search (optional)
399 | :param float dynamic_score_percentage: Percentage of dynamic score to consider (optional)
400 | :raise SearchError: If the search fails
401 | :return: :class:`SearchResult ` object
402 | :rtype: :class:`videodb.search.SearchResult`
403 | """
404 | search = SearchFactory(self._connection).get_search(search_type)
405 | return search.search_inside_collection(
406 | collection_id=self.id,
407 | query=query,
408 | search_type=search_type,
409 | index_type=index_type,
410 | result_threshold=result_threshold,
411 | score_threshold=score_threshold,
412 | dynamic_score_percentage=dynamic_score_percentage,
413 | filter=filter,
414 | )
415 |
416 | def search_title(self, query) -> List[Video]:
417 | search_data = self._connection.post(
418 | path=f"{ApiPath.collection}/{self.id}/{ApiPath.search}/{ApiPath.title}",
419 | data={
420 | "query": query,
421 | "search_type": SearchType.llm,
422 | },
423 | )
424 | return [
425 | {"video": Video(self._connection, **result.get("video"))}
426 | for result in search_data
427 | ]
428 |
429 | def upload(
430 | self,
431 | file_path: str = None,
432 | url: Optional[str] = None,
433 | media_type: Optional[str] = None,
434 | name: Optional[str] = None,
435 | description: Optional[str] = None,
436 | callback_url: Optional[str] = None,
437 | ) -> Union[Video, Audio, Image, None]:
438 | """Upload a file to the collection.
439 |
440 | :param str file_path: Path to the file to be uploaded
441 | :param str url: URL of the file to be uploaded
442 | :param MediaType media_type: MediaType object (optional)
443 | :param str name: Name of the file (optional)
444 | :param str description: Description of the file (optional)
445 | :param str callback_url: URL to receive the callback (optional)
446 | :return: :class:`Video `, or :class:`Audio `, or :class:`Image ` object
447 | :rtype: Union[ :class:`videodb.video.Video`, :class:`videodb.audio.Audio`, :class:`videodb.image.Image`]
448 | """
449 | upload_data = upload(
450 | self._connection,
451 | file_path,
452 | url,
453 | media_type,
454 | name,
455 | description,
456 | callback_url,
457 | )
458 | media_id = upload_data.get("id", "")
459 | if media_id.startswith("m-"):
460 | return Video(self._connection, **upload_data)
461 | elif media_id.startswith("a-"):
462 | return Audio(self._connection, **upload_data)
463 | elif media_id.startswith("img-"):
464 | return Image(self._connection, **upload_data)
465 |
466 | def make_public(self):
467 | """Make the collection public.
468 |
469 | :return: None
470 | :rtype: None
471 | """
472 | self._connection.patch(
473 | path=f"{ApiPath.collection}/{self.id}", data={"is_public": True}
474 | )
475 | self.is_public = True
476 |
477 | def make_private(self):
478 | """Make the collection private.
479 |
480 | :return: None
481 | :rtype: None
482 | """
483 | self._connection.patch(
484 | path=f"{ApiPath.collection}/{self.id}", data={"is_public": False}
485 | )
486 | self.is_public = False
487 |
--------------------------------------------------------------------------------
/videodb/exceptions.py:
--------------------------------------------------------------------------------
1 | """videodb.exceptions
2 |
3 | This module contains the set of Videodb's exceptions.
4 | """
5 |
6 |
7 | class VideodbError(Exception):
8 | """
9 | Base class for all videodb exceptions.
10 | """
11 |
12 | def __init__(self, message: str = "An error occurred", cause=None):
13 | super(VideodbError, self).__init__(message)
14 | self.cause = cause
15 |
16 | def __str__(self):
17 | return f"{super(VideodbError, self).__str__()} {'caused by ' + str(self.cause) if self.cause else ''}"
18 |
19 |
20 | class AuthenticationError(VideodbError):
21 | """
22 | Raised when authentication is required or failed.
23 | """
24 |
25 | def __init__(self, message, response=None):
26 | super(AuthenticationError, self).__init__(message)
27 | self.response = response
28 |
29 |
30 | class InvalidRequestError(VideodbError):
31 | """
32 | Raised when a request is invalid.
33 | """
34 |
35 | def __init__(self, message, response=None):
36 | super(InvalidRequestError, self).__init__(message)
37 | self.response = response
38 |
39 |
40 | class RequestTimeoutError(VideodbError):
41 | """
42 | Raised when a request times out.
43 | """
44 |
45 | def __init__(self, message, response=None):
46 | super(RequestTimeoutError, self).__init__(message)
47 | self.response = response
48 |
49 |
50 | class SearchError(VideodbError):
51 | """
52 | Raised when a search is invalid.
53 | """
54 |
55 | def __init__(self, message):
56 | super(SearchError, self).__init__(message)
57 |
--------------------------------------------------------------------------------
/videodb/image.py:
--------------------------------------------------------------------------------
1 | from videodb._constants import (
2 | ApiPath,
3 | )
4 |
5 |
6 | class Image:
7 | """Image class to interact with the Image
8 |
9 | :ivar str id: Unique identifier for the image
10 | :ivar str collection_id: ID of the collection this image belongs to
11 | :ivar str name: Name of the image file
12 | :ivar str url: URL of the image
13 | """
14 |
15 | def __init__(self, _connection, id: str, collection_id: str, **kwargs) -> None:
16 | self._connection = _connection
17 | self.id = id
18 | self.collection_id = collection_id
19 | self.name = kwargs.get("name", None)
20 | self.url = kwargs.get("url", None)
21 |
22 | def __repr__(self) -> str:
23 | return (
24 | f"Image("
25 | f"id={self.id}, "
26 | f"collection_id={self.collection_id}, "
27 | f"name={self.name}, "
28 | f"url={self.url})"
29 | )
30 |
31 | def generate_url(self) -> str:
32 | """Generate the signed url of the image.
33 |
34 | :raises InvalidRequestError: If the get_url fails
35 | :return: The signed url of the image
36 | :rtype: str
37 | """
38 | url_data = self._connection.post(
39 | path=f"{ApiPath.image}/{self.id}/{ApiPath.generate_url}",
40 | params={"collection_id": self.collection_id},
41 | )
42 | return url_data.get("signed_url", None)
43 |
44 | def delete(self) -> None:
45 | """Delete the image.
46 |
47 | :raises InvalidRequestError: If the delete fails
48 | :return: None if the delete is successful
49 | :rtype: None
50 | """
51 | self._connection.delete(f"{ApiPath.image}/{self.id}")
52 |
53 |
54 | class Frame(Image):
55 | """Frame class to interact with video frames
56 |
57 | :ivar str id: Unique identifier for the frame
58 | :ivar str video_id: ID of the video this frame belongs to
59 | :ivar str scene_id: ID of the scene this frame belongs to
60 | :ivar str url: URL of the frame
61 | :ivar float frame_time: Timestamp of the frame in the video
62 | :ivar str description: Description of the frame contents
63 | """
64 |
65 | def __init__(
66 | self,
67 | _connection,
68 | id: str,
69 | video_id: str,
70 | scene_id: str,
71 | url: str,
72 | frame_time: float,
73 | description: str,
74 | ):
75 | super().__init__(_connection=_connection, id=id, collection_id=None, url=url)
76 | self.scene_id = scene_id
77 | self.video_id = video_id
78 | self.frame_time = frame_time
79 | self.description = description
80 |
81 | def __repr__(self) -> str:
82 | return (
83 | f"Frame("
84 | f"id={self.id}, "
85 | f"video_id={self.video_id}, "
86 | f"scene_id={self.scene_id}, "
87 | f"url={self.url}, "
88 | f"frame_time={self.frame_time}, "
89 | f"description={self.description})"
90 | )
91 |
92 | def to_json(self):
93 | return {
94 | "id": self.id,
95 | "video_id": self.video_id,
96 | "scene_id": self.scene_id,
97 | "url": self.url,
98 | "frame_time": self.frame_time,
99 | "description": self.description,
100 | }
101 |
102 | def describe(self, prompt: str = None, model_name=None):
103 | """Describe the frame.
104 |
105 | :param str prompt: (optional) The prompt to use for the description
106 | :param str model_name: (optional) The model to use for the description
107 | :return: The description of the frame
108 | :rtype: str
109 | """
110 | description_data = self._connection.post(
111 | path=f"{ApiPath.video}/{self.video_id}/{ApiPath.frame}/{self.id}/{ApiPath.describe}",
112 | data={"prompt": prompt, "model_name": model_name},
113 | )
114 | self.description = description_data.get("description", None)
115 | return self.description
116 |
--------------------------------------------------------------------------------
/videodb/rtstream.py:
--------------------------------------------------------------------------------
1 | from videodb._constants import (
2 | ApiPath,
3 | SceneExtractionType,
4 | )
5 |
6 |
7 | class RTStreamSceneIndex:
8 | """RTStreamSceneIndex class to interact with the rtstream scene index
9 |
10 | :ivar str rtstream_index_id: Unique identifier for the rtstream scene index
11 | :ivar str rtstream_id: ID of the rtstream this scene index belongs to
12 | :ivar str extraction_type: Type of extraction
13 | :ivar dict extraction_config: Configuration for extraction
14 | :ivar str prompt: Prompt for scene extraction
15 | :ivar str name: Name of the scene index
16 | :ivar str status: Status of the scene index
17 | """
18 |
19 | def __init__(
20 | self, _connection, rtstream_index_id: str, rtstream_id, **kwargs
21 | ) -> None:
22 | self._connection = _connection
23 | self.rtstream_index_id = rtstream_index_id
24 | self.rtstream_id = rtstream_id
25 | self.extraction_type = kwargs.get("extraction_type", None)
26 | self.extraction_config = kwargs.get("extraction_config", None)
27 | self.prompt = kwargs.get("prompt", None)
28 | self.name = kwargs.get("name", None)
29 | self.status = kwargs.get("status", None)
30 |
31 | def __repr__(self) -> str:
32 | return (
33 | f"RTStreamSceneIndex("
34 | f"rtstream_index_id={self.rtstream_index_id}, "
35 | f"rtstream_id={self.rtstream_id}, "
36 | f"extraction_type={self.extraction_type}, "
37 | f"extraction_config={self.extraction_config}, "
38 | f"prompt={self.prompt}, "
39 | f"name={self.name}, "
40 | f"status={self.status})"
41 | )
42 |
43 | def get_scenes(self, start: int = None, end: int = None, page=1, page_size=100):
44 | """Get rtstream scene index scenes.
45 |
46 | :param int start: Start time of the scenes
47 | :param int end: End time of the scenes
48 | :param int page: Page number
49 | :param int page_size: Number of scenes per page
50 | :return: List of scenes
51 | :rtype: List[dict]
52 | """
53 | params = {"page": page, "page_size": page_size}
54 | if start and end:
55 | params["start"] = start
56 | params["end"] = end
57 |
58 | index_data = self._connection.get(
59 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{ApiPath.scene}/{self.rtstream_index_id}",
60 | params=params,
61 | )
62 | if not index_data:
63 | return None
64 | return {
65 | "scenes": index_data.get("scene_index_records", []),
66 | "next_page": index_data.get("next_page", False),
67 | }
68 |
69 | def start(self):
70 | """Start the scene index.
71 |
72 | :return: None
73 | :rtype: None
74 | """
75 | self._connection.patch(
76 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{ApiPath.scene}/{self.rtstream_index_id}/{ApiPath.status}",
77 | data={"action": "start"},
78 | )
79 | self.status = "connected"
80 |
81 | def stop(self):
82 | """Stop the scene index.
83 |
84 | :return: None
85 | :rtype: None
86 | """
87 | self._connection.patch(
88 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{ApiPath.scene}/{self.rtstream_index_id}/{ApiPath.status}",
89 | data={"action": "stop"},
90 | )
91 | self.status = "stopped"
92 |
93 | def create_alert(self, event_id, callback_url) -> str:
94 | """Create an event alert.
95 |
96 | :param str event_id: ID of the event
97 | :param str callback_url: URL to receive the alert callback
98 | :return: Alert ID
99 | :rtype: str
100 | """
101 | alert_data = self._connection.post(
102 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{self.rtstream_index_id}/{ApiPath.alert}",
103 | data={
104 | "event_id": event_id,
105 | "callback_url": callback_url,
106 | },
107 | )
108 | return alert_data.get("alert_id", None)
109 |
110 | def list_alerts(self):
111 | """List all alerts for the rtstream scene index.
112 |
113 | :return: List of alerts
114 | :rtype: List[dict]
115 | """
116 | alert_data = self._connection.get(
117 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{self.rtstream_index_id}/{ApiPath.alert}"
118 | )
119 | return alert_data.get("alerts", [])
120 |
121 | def enable_alert(self, alert_id):
122 | """Enable an alert.
123 |
124 | :param str alert_id: ID of the alert
125 | :return: None
126 | :rtype: None
127 | """
128 | self._connection.patch(
129 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{self.rtstream_index_id}/{ApiPath.alert}/{alert_id}/{ApiPath.status}",
130 | data={"action": "enable"},
131 | )
132 |
133 | def disable_alert(self, alert_id):
134 | """Disable an alert.
135 |
136 | :param str alert_id: ID of the alert
137 | :return: None
138 | :rtype: None
139 | """
140 | self._connection.patch(
141 | f"{ApiPath.rtstream}/{self.rtstream_id}/{ApiPath.index}/{self.rtstream_index_id}/{ApiPath.alert}/{alert_id}/{ApiPath.status}",
142 | data={"action": "disable"},
143 | )
144 |
145 |
146 | class RTStream:
147 | """RTStream class to interact with the RTStream
148 |
149 | :ivar str id: Unique identifier for the rtstream
150 | :ivar str name: Name of the rtstream
151 | :ivar str collection_id: ID of the collection this rtstream belongs to
152 | :ivar str created_at: Timestamp of the rtstream creation
153 | :ivar int sample_rate: Sample rate of the rtstream
154 | :ivar str status: Status of the rtstream
155 | """
156 |
157 | def __init__(self, _connection, id: str, **kwargs) -> None:
158 | self._connection = _connection
159 | self.id = id
160 | self.name = kwargs.get("name", None)
161 | self.collection_id = kwargs.get("collection_id", None)
162 | self.created_at = kwargs.get("created_at", None)
163 | self.sample_rate = kwargs.get("sample_rate", None)
164 | self.status = kwargs.get("status", None)
165 |
166 | def __repr__(self) -> str:
167 | return (
168 | f"RTStream("
169 | f"id={self.id}, "
170 | f"name={self.name}, "
171 | f"collection_id={self.collection_id}, "
172 | f"created_at={self.created_at}, "
173 | f"sample_rate={self.sample_rate}, "
174 | f"status={self.status})"
175 | )
176 |
177 | def start(self):
178 | """Connect to the rtstream.
179 |
180 | :return: None
181 | :rtype: None
182 | """
183 | self._connection.patch(
184 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.status}",
185 | data={"action": "start"},
186 | )
187 | self.status = "connected"
188 |
189 | def stop(self):
190 | """Disconnect from the rtstream.
191 |
192 | :return: None
193 | :rtype: None
194 | """
195 | self._connection.patch(
196 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.status}",
197 | data={"action": "stop"},
198 | )
199 | self.status = "stopped"
200 |
201 | def generate_stream(self, start, end):
202 | """Generate a stream from the rtstream.
203 |
204 | :param int start: Start time of the stream in Unix timestamp format
205 | :param int end: End time of the stream in Unix timestamp format
206 | :return: Stream URL
207 | :rtype: str
208 | """
209 | stream_data = self._connection.get(
210 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.stream}",
211 | params={"start": start, "end": end},
212 | )
213 | return stream_data.get("stream_url", None)
214 |
215 | def index_scenes(
216 | self,
217 | extraction_type=SceneExtractionType.time_based,
218 | extraction_config={"time": 2, "frame_count": 5},
219 | prompt="Describe the scene",
220 | model_name=None,
221 | model_config={},
222 | name=None,
223 | ):
224 | """Index scenes from the rtstream.
225 |
226 | :param str extraction_type: Type of extraction
227 | :param dict extraction_config: Configuration for extraction
228 | :param str prompt: Prompt for scene extraction
229 | :param str model_name: Name of the model
230 | :param dict model_config: Configuration for the model
231 | :param str name: Name of the scene index
232 | :return: Scene index, :class:`RTStreamSceneIndex ` object
233 | :rtype: :class:`videodb.rtstream.RTStreamSceneIndex`
234 | """
235 | index_data = self._connection.post(
236 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.index}/{ApiPath.scene}",
237 | data={
238 | "extraction_type": extraction_type,
239 | "extraction_config": extraction_config,
240 | "prompt": prompt,
241 | "model_name": model_name,
242 | "model_config": model_config,
243 | "name": name,
244 | },
245 | )
246 | if not index_data:
247 | return None
248 | return RTStreamSceneIndex(
249 | _connection=self._connection,
250 | rtstream_index_id=index_data.get("rtstream_index_id"),
251 | rtstream_id=self.id,
252 | extraction_type=index_data.get("extraction_type"),
253 | extraction_config=index_data.get("extraction_config"),
254 | prompt=index_data.get("prompt"),
255 | name=index_data.get("name"),
256 | status=index_data.get("status"),
257 | )
258 |
259 | def list_scene_indexes(self):
260 | """List all scene indexes for the rtstream.
261 |
262 | :return: List of :class:`RTStreamSceneIndex ` objects
263 | :rtype: List[:class:`videodb.rtstream.RTStreamSceneIndex`]
264 | """
265 | index_data = self._connection.get(
266 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.index}/{ApiPath.scene}"
267 | )
268 | return [
269 | RTStreamSceneIndex(
270 | _connection=self._connection,
271 | rtstream_index_id=index.get("rtstream_index_id"),
272 | rtstream_id=self.id,
273 | extraction_type=index.get("extraction_type"),
274 | extraction_config=index.get("extraction_config"),
275 | prompt=index.get("prompt"),
276 | name=index.get("name"),
277 | status=index.get("status"),
278 | )
279 | for index in index_data.get("scene_indexes", [])
280 | ]
281 |
282 | def get_scene_index(self, index_id: str) -> RTStreamSceneIndex:
283 | """Get a scene index by its ID.
284 |
285 | :param str index_id: ID of the scene index
286 | :return: Scene index, :class:`RTStreamSceneIndex ` object
287 | :rtype: :class:`videodb.rtstream.RTStreamSceneIndex`
288 | """
289 | index_data = self._connection.get(
290 | f"{ApiPath.rtstream}/{self.id}/{ApiPath.index}/{index_id}"
291 | )
292 | return RTStreamSceneIndex(
293 | _connection=self._connection,
294 | rtstream_index_id=index_data.get("rtstream_index_id"),
295 | rtstream_id=self.id,
296 | extraction_type=index_data.get("extraction_type"),
297 | extraction_config=index_data.get("extraction_config"),
298 | prompt=index_data.get("prompt"),
299 | name=index_data.get("name"),
300 | status=index_data.get("status"),
301 | )
302 |
--------------------------------------------------------------------------------
/videodb/scene.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from videodb._constants import ApiPath
4 |
5 | from videodb.image import Frame
6 |
7 |
8 | class Scene:
9 | """Scene class to interact with video scenes
10 |
11 | :ivar str id: Unique identifier for the scene
12 | :ivar str video_id: ID of the video this scene belongs to
13 | :ivar float start: Start time of the scene in seconds
14 | :ivar float end: End time of the scene in seconds
15 | :ivar List[Frame] frames: List of frames in the scene
16 | :ivar str description: Description of the scene contents
17 | """
18 |
19 | def __init__(
20 | self,
21 | video_id: str,
22 | start: float,
23 | end: float,
24 | description: str,
25 | id: str = None,
26 | frames: List[Frame] = [],
27 | metadata: dict = {},
28 | connection=None,
29 | ):
30 | self.id = id
31 | self.video_id = video_id
32 | self.start = start
33 | self.end = end
34 | self.frames: List[Frame] = frames
35 | self.description = description
36 | self.metadata = metadata
37 | self._connection = connection
38 |
39 | def __repr__(self) -> str:
40 | return (
41 | f"Scene("
42 | f"id={self.id}, "
43 | f"video_id={self.video_id}, "
44 | f"start={self.start}, "
45 | f"end={self.end}, "
46 | f"frames={self.frames}, "
47 | f"description={self.description}), "
48 | f"metadata={self.metadata})"
49 | )
50 |
51 | def to_json(self):
52 | return {
53 | "id": self.id,
54 | "video_id": self.video_id,
55 | "start": self.start,
56 | "end": self.end,
57 | "frames": [frame.to_json() for frame in self.frames],
58 | "description": self.description,
59 | "metadata": self.metadata,
60 | }
61 |
62 | def describe(self, prompt: str = None, model_name=None) -> None:
63 | """Describe the scene.
64 |
65 | :param str prompt: (optional) The prompt to use for the description
66 | :param str model_name: (optional) The model to use for the description
67 | :return: The description of the scene
68 | :rtype: str
69 | """
70 | if self._connection is None:
71 | raise ValueError("Connection is required to describe a scene")
72 | description_data = self._connection.post(
73 | path=f"{ApiPath.video}/{self.video_id}/{ApiPath.scene}/{self.id}/{ApiPath.describe}",
74 | data={"prompt": prompt, "model_name": model_name},
75 | )
76 | self.description = description_data.get("description", None)
77 | return self.description
78 |
79 |
80 | class SceneCollection:
81 | """SceneCollection class to interact with collections of scenes
82 |
83 | :ivar str id: Unique identifier for the scene collection
84 | :ivar str video_id: ID of the video these scenes belong to
85 | :ivar dict config: Configuration settings for the scene collection
86 | :ivar List[Scene] scenes: List of scenes in the collection
87 | """
88 |
89 | def __init__(
90 | self,
91 | _connection,
92 | id: str,
93 | video_id: str,
94 | config: dict,
95 | scenes: List[Scene],
96 | ) -> None:
97 | self._connection = _connection
98 | self.id = id
99 | self.video_id = video_id
100 | self.config: dict = config
101 | self.scenes: List[Scene] = scenes
102 |
103 | def __repr__(self) -> str:
104 | return (
105 | f"SceneCollection("
106 | f"id={self.id}, "
107 | f"video_id={self.video_id}, "
108 | f"config={self.config}, "
109 | f"scenes={self.scenes})"
110 | )
111 |
112 | def delete(self) -> None:
113 | """Delete the scene collection.
114 |
115 | :raises InvalidRequestError: If the delete fails
116 | :return: None if the delete is successful
117 | :rtype: None
118 | """
119 | self._connection.delete(
120 | path=f"{ApiPath.video}/{self.video_id}/{ApiPath.scenes}/{self.id}"
121 | )
122 |
--------------------------------------------------------------------------------
/videodb/search.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from videodb._utils._video import play_stream
3 | from videodb._constants import (
4 | IndexType,
5 | SearchType,
6 | ApiPath,
7 | SemanticSearchDefaultValues,
8 | )
9 | from videodb.exceptions import (
10 | SearchError,
11 | )
12 | from typing import Optional, List
13 | from videodb.shot import Shot
14 |
15 |
16 | class SearchResult:
17 | """SearchResult class to interact with search results
18 |
19 | :ivar str collection_id: ID of the collection this search result belongs to
20 | :ivar str stream_url: URL to stream the search result
21 | :ivar str player_url: URL to play the search result in a player
22 | :ivar list[Shot] shots: List of shots in the search result
23 | """
24 |
25 | def __init__(self, _connection, **kwargs):
26 | self._connection = _connection
27 | self.shots = []
28 | self.stream_url = None
29 | self.player_url = None
30 | self.collection_id = "default"
31 | self._results = kwargs.get("results", [])
32 | self._format_results()
33 |
34 | def _format_results(self):
35 | for result in self._results:
36 | self.collection_id = result.get("collection_id")
37 | for doc in result.get("docs"):
38 | self.shots.append(
39 | Shot(
40 | self._connection,
41 | result.get("video_id"),
42 | result.get("length"),
43 | result.get("title"),
44 | doc.get("start"),
45 | doc.get("end"),
46 | doc.get("text"),
47 | doc.get("score"),
48 | )
49 | )
50 |
51 | def __repr__(self) -> str:
52 | return (
53 | f"SearchResult("
54 | f"collection_id={self.collection_id}, "
55 | f"stream_url={self.stream_url}, "
56 | f"player_url={self.player_url}, "
57 | f"shots={self.shots})"
58 | )
59 |
60 | def get_shots(self) -> List[Shot]:
61 | return self.shots
62 |
63 | def compile(self) -> str:
64 | """Compile the search result shots into a stream url.
65 |
66 | :raises SearchError: If no shots are found in the search results
67 | :return: The stream url
68 | :rtype: str
69 | """
70 | if self.stream_url:
71 | return self.stream_url
72 | elif self.shots:
73 | compile_data = self._connection.post(
74 | path=f"{ApiPath.compile}",
75 | data=[
76 | {
77 | "video_id": shot.video_id,
78 | "collection_id": self.collection_id,
79 | "shots": [(shot.start, shot.end)],
80 | }
81 | for shot in self.shots
82 | ],
83 | )
84 | self.stream_url = compile_data.get("stream_url")
85 | self.player_url = compile_data.get("player_url")
86 | return self.stream_url
87 |
88 | else:
89 | raise SearchError("No shots found in search results to compile")
90 |
91 | def play(self) -> str:
92 | """Generate a stream url for the shot and open it in the default browser.
93 |
94 | :return: The stream url
95 | :rtype: str
96 | """
97 | self.compile()
98 | return play_stream(self.stream_url)
99 |
100 |
101 | class Search(ABC):
102 | """Search interface inside video or collection"""
103 |
104 | @abstractmethod
105 | def search_inside_video(self, *args, **kwargs):
106 | pass
107 |
108 | @abstractmethod
109 | def search_inside_collection(self, *args, **kwargs):
110 | pass
111 |
112 |
113 | class SemanticSearch(Search):
114 | def __init__(self, _connection):
115 | self._connection = _connection
116 |
117 | def search_inside_video(
118 | self,
119 | video_id: str,
120 | query: str,
121 | search_type: str,
122 | index_type: str,
123 | result_threshold: Optional[int] = None,
124 | score_threshold: Optional[float] = None,
125 | dynamic_score_percentage: Optional[float] = None,
126 | **kwargs,
127 | ):
128 | search_data = self._connection.post(
129 | path=f"{ApiPath.video}/{video_id}/{ApiPath.search}",
130 | data={
131 | "search_type": search_type,
132 | "index_type": index_type,
133 | "query": query,
134 | "score_threshold": score_threshold
135 | or SemanticSearchDefaultValues.score_threshold,
136 | "result_threshold": result_threshold
137 | or SemanticSearchDefaultValues.result_threshold,
138 | "dynamic_score_percentage": dynamic_score_percentage,
139 | **kwargs,
140 | },
141 | )
142 | return SearchResult(self._connection, **search_data)
143 |
144 | def search_inside_collection(
145 | self,
146 | collection_id: str,
147 | query: str,
148 | search_type: str,
149 | index_type: str,
150 | result_threshold: Optional[int] = None,
151 | score_threshold: Optional[float] = None,
152 | dynamic_score_percentage: Optional[float] = None,
153 | **kwargs,
154 | ):
155 | search_data = self._connection.post(
156 | path=f"{ApiPath.collection}/{collection_id}/{ApiPath.search}",
157 | data={
158 | "search_type": search_type,
159 | "index_type": index_type,
160 | "query": query,
161 | "score_threshold": score_threshold
162 | or SemanticSearchDefaultValues.score_threshold,
163 | "result_threshold": result_threshold
164 | or SemanticSearchDefaultValues.result_threshold,
165 | "dynamic_score_percentage": dynamic_score_percentage,
166 | **kwargs,
167 | },
168 | )
169 | return SearchResult(self._connection, **search_data)
170 |
171 |
172 | class KeywordSearch(Search):
173 | def __init__(self, _connection):
174 | self._connection = _connection
175 |
176 | def search_inside_video(
177 | self,
178 | video_id: str,
179 | query: str,
180 | search_type: str,
181 | index_type: str,
182 | result_threshold: Optional[int] = None,
183 | score_threshold: Optional[float] = None,
184 | dynamic_score_percentage: Optional[float] = None,
185 | **kwargs,
186 | ):
187 | search_data = self._connection.post(
188 | path=f"{ApiPath.video}/{video_id}/{ApiPath.search}",
189 | data={
190 | "search_type": search_type,
191 | "index_type": index_type,
192 | "query": query,
193 | "score_threshold": score_threshold,
194 | "result_threshold": result_threshold,
195 | **kwargs,
196 | },
197 | )
198 | return SearchResult(self._connection, **search_data)
199 |
200 | def search_inside_collection(self, **kwargs):
201 | raise NotImplementedError("Keyword search will be implemented in the future")
202 |
203 |
204 | class SceneSearch(Search):
205 | def __init__(self, _connection):
206 | self._connection = _connection
207 |
208 | def search_inside_video(
209 | self,
210 | video_id: str,
211 | query: str,
212 | search_type: str,
213 | index_type: str,
214 | result_threshold: Optional[int] = None,
215 | score_threshold: Optional[float] = None,
216 | dynamic_score_percentage: Optional[float] = None,
217 | **kwargs,
218 | ):
219 | search_data = self._connection.post(
220 | path=f"{ApiPath.video}/{video_id}/{ApiPath.search}",
221 | data={
222 | "search_type": search_type,
223 | "index_type": IndexType.scene,
224 | "query": query,
225 | "score_threshold": score_threshold,
226 | "result_threshold": result_threshold,
227 | "dynamic_score_percentage": dynamic_score_percentage,
228 | **kwargs,
229 | },
230 | )
231 | return SearchResult(self._connection, **search_data)
232 |
233 | def search_inside_collection(self, **kwargs):
234 | raise NotImplementedError("Scene search will be implemented in the future")
235 |
236 |
237 | search_type = {
238 | SearchType.semantic: SemanticSearch,
239 | SearchType.keyword: KeywordSearch,
240 | SearchType.scene: SceneSearch,
241 | }
242 |
243 |
244 | class SearchFactory:
245 | def __init__(self, _connection):
246 | self._connection = _connection
247 |
248 | def get_search(self, type: str):
249 | if type not in search_type:
250 | raise SearchError(
251 | f"Invalid search type: {type}. Valid search types are: {list(search_type.keys())}"
252 | )
253 | return search_type[type](self._connection)
254 |
--------------------------------------------------------------------------------
/videodb/shot.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | from typing import Optional
4 | from videodb._utils._video import play_stream
5 | from videodb._constants import (
6 | ApiPath,
7 | )
8 |
9 |
10 | class Shot:
11 | """Shot class to interact with video shots
12 |
13 | :ivar str video_id: Unique identifier for the video
14 | :ivar float video_length: Duration of the video in seconds
15 | :ivar str video_title: Title of the video
16 | :ivar float start: Start time of the shot in seconds
17 | :ivar float end: End time of the shot in seconds
18 | :ivar str text: Text content of the shot
19 | :ivar int search_score: Search relevance score
20 | :ivar str stream_url: URL to stream the shot
21 | :ivar str player_url: URL to play the shot in a player
22 | """
23 |
24 | def __init__(
25 | self,
26 | _connection,
27 | video_id: str,
28 | video_length: float,
29 | video_title: str,
30 | start: float,
31 | end: float,
32 | text: Optional[str] = None,
33 | search_score: Optional[int] = None,
34 | ) -> None:
35 | self._connection = _connection
36 | self.video_id = video_id
37 | self.video_length = video_length
38 | self.video_title = video_title
39 | self.start = start
40 | self.end = end
41 | self.text = text
42 | self.search_score = search_score
43 | self.stream_url = None
44 | self.player_url = None
45 |
46 | def __repr__(self) -> str:
47 | return (
48 | f"Shot("
49 | f"video_id={self.video_id}, "
50 | f"video_title={self.video_title}, "
51 | f"start={self.start}, "
52 | f"end={self.end}, "
53 | f"text={self.text}, "
54 | f"search_score={self.search_score}, "
55 | f"stream_url={self.stream_url}, "
56 | f"player_url={self.player_url})"
57 | )
58 |
59 | def __getitem__(self, key):
60 | """Get an item from the shot object"""
61 | return self.__dict__[key]
62 |
63 | def generate_stream(self) -> str:
64 | """Generate a stream url for the shot.
65 |
66 | :return: The stream url
67 | :rtype: str
68 | """
69 |
70 | if self.stream_url:
71 | return self.stream_url
72 | else:
73 | stream_data = self._connection.post(
74 | path=f"{ApiPath.video}/{self.video_id}/{ApiPath.stream}",
75 | data={
76 | "timeline": [(self.start, self.end)],
77 | "length": self.video_length,
78 | },
79 | )
80 | self.stream_url = stream_data.get("stream_url")
81 | self.player_url = stream_data.get("player_url")
82 | return self.stream_url
83 |
84 | def play(self) -> str:
85 | """Generate a stream url for the shot and open it in the default browser/ notebook.
86 |
87 | :return: The stream url
88 | :rtype: str
89 | """
90 | self.generate_stream()
91 | return play_stream(self.stream_url)
92 |
--------------------------------------------------------------------------------
/videodb/timeline.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | from videodb._constants import ApiPath
4 | from videodb.asset import VideoAsset, AudioAsset, ImageAsset, TextAsset
5 |
6 |
7 | class Timeline(object):
8 | def __init__(self, connection) -> None:
9 | self._connection = connection
10 | self._timeline = []
11 | self.stream_url = None
12 | self.player_url = None
13 |
14 | def to_json(self) -> dict:
15 | timeline_json = []
16 | for asset in self._timeline:
17 | if isinstance(asset, tuple):
18 | overlay_start, audio_asset = asset
19 | asset_json = audio_asset.to_json()
20 | asset_json["overlay_start"] = overlay_start
21 | timeline_json.append(asset_json)
22 | else:
23 | timeline_json.append(asset.to_json())
24 | return {"timeline": timeline_json}
25 |
26 | def add_inline(self, asset: VideoAsset) -> None:
27 | """Add a video asset to the timeline
28 |
29 | :param VideoAsset asset: The video asset to add, :class:`VideoAsset` object
30 | :raises ValueError: If asset is not of type :class:`VideoAsset`
31 | :return: None
32 | :rtype: None
33 | """
34 | if not isinstance(asset, VideoAsset):
35 | raise ValueError("asset must be of type VideoAsset")
36 | self._timeline.append(asset)
37 |
38 | def add_overlay(
39 | self, start: int, asset: Union[AudioAsset, ImageAsset, TextAsset]
40 | ) -> None:
41 | """Add an overlay asset to the timeline
42 |
43 | :param int start: The start time of the overlay asset
44 | :param Union[AudioAsset, ImageAsset, TextAsset] asset: The overlay asset to add, :class:`AudioAsset `, :class:`ImageAsset `, :class:`TextAsset ` object
45 | :return: None
46 | :rtype: None
47 | """
48 | if (
49 | not isinstance(asset, AudioAsset)
50 | and not isinstance(asset, ImageAsset)
51 | and not isinstance(asset, TextAsset)
52 | ):
53 | raise ValueError(
54 | "asset must be of type AudioAsset, ImageAsset or TextAsset"
55 | )
56 | self._timeline.append((start, asset))
57 |
58 | def generate_stream(self) -> str:
59 | """Generate a stream url for the timeline
60 |
61 | :return: The stream url
62 | :rtype: str
63 | """
64 | stream_data = self._connection.post(
65 | path=f"{ApiPath.timeline}",
66 | data={
67 | "request_type": "compile",
68 | "timeline": self.to_json().get("timeline"),
69 | },
70 | )
71 | self.stream_url = stream_data.get("stream_url")
72 | self.player_url = stream_data.get("player_url")
73 | return stream_data.get("stream_url", None)
74 |
--------------------------------------------------------------------------------
/videodb/video.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Union, List, Dict, Tuple, Any
2 | from videodb._utils._video import play_stream
3 | from videodb._constants import (
4 | ApiPath,
5 | IndexType,
6 | SceneExtractionType,
7 | SearchType,
8 | Segmenter,
9 | SubtitleStyle,
10 | Workflows,
11 | )
12 | from videodb.image import Image, Frame
13 | from videodb.scene import Scene, SceneCollection
14 | from videodb.search import SearchFactory, SearchResult
15 | from videodb.shot import Shot
16 |
17 |
18 | class Video:
19 | """Video class to interact with the Video
20 |
21 | :ivar str id: Unique identifier for the video
22 | :ivar str collection_id: ID of the collection this video belongs to
23 | :ivar str stream_url: URL to stream the video
24 | :ivar str player_url: URL to play the video in a player
25 | :ivar str name: Name of the video file
26 | :ivar str description: Description of the video
27 | :ivar str thumbnail_url: URL of the video thumbnail
28 | :ivar float length: Duration of the video in seconds
29 | :ivar list transcript: Timestamped transcript segments
30 | :ivar str transcript_text: Full transcript text
31 | :ivar list scenes: List of scenes in the video
32 | """
33 |
34 | def __init__(self, _connection, id: str, collection_id: str, **kwargs) -> None:
35 | self._connection = _connection
36 | self.id = id
37 | self.collection_id = collection_id
38 | self.stream_url = kwargs.get("stream_url", None)
39 | self.player_url = kwargs.get("player_url", None)
40 | self.name = kwargs.get("name", None)
41 | self.description = kwargs.get("description", None)
42 | self.thumbnail_url = kwargs.get("thumbnail_url", None)
43 | self.length = float(kwargs.get("length", 0.0))
44 | self.transcript = kwargs.get("transcript", None)
45 | self.transcript_text = kwargs.get("transcript_text", None)
46 | self.scenes = kwargs.get("scenes", None)
47 |
48 | def __repr__(self) -> str:
49 | return (
50 | f"Video("
51 | f"id={self.id}, "
52 | f"collection_id={self.collection_id}, "
53 | f"stream_url={self.stream_url}, "
54 | f"player_url={self.player_url}, "
55 | f"name={self.name}, "
56 | f"description={self.description}, "
57 | f"thumbnail_url={self.thumbnail_url}, "
58 | f"length={self.length})"
59 | )
60 |
61 | def __getitem__(self, key):
62 | return self.__dict__[key]
63 |
64 | def search(
65 | self,
66 | query: str,
67 | search_type: Optional[str] = SearchType.semantic,
68 | index_type: Optional[str] = IndexType.spoken_word,
69 | result_threshold: Optional[int] = None,
70 | score_threshold: Optional[float] = None,
71 | dynamic_score_percentage: Optional[float] = None,
72 | filter: List[Dict[str, Any]] = [],
73 | **kwargs,
74 | ) -> SearchResult:
75 | """Search for a query in the video.
76 |
77 | :param str query: Query to search for.
78 | :param SearchType search_type: (optional) Type of search to perform :class:`SearchType ` object
79 | :param IndexType index_type: (optional) Type of index to search :class:`IndexType ` object
80 | :param int result_threshold: (optional) Number of results to return
81 | :param float score_threshold: (optional) Threshold score for the search
82 | :param float dynamic_score_percentage: (optional) Percentage of dynamic score to consider
83 | :raise SearchError: If the search fails
84 | :return: :class:`SearchResult ` object
85 | :rtype: :class:`videodb.search.SearchResult`
86 | """
87 | search = SearchFactory(self._connection).get_search(search_type)
88 | return search.search_inside_video(
89 | video_id=self.id,
90 | query=query,
91 | search_type=search_type,
92 | index_type=index_type,
93 | result_threshold=result_threshold,
94 | score_threshold=score_threshold,
95 | dynamic_score_percentage=dynamic_score_percentage,
96 | filter=filter,
97 | **kwargs,
98 | )
99 |
100 | def delete(self) -> None:
101 | """Delete the video.
102 |
103 | :raises InvalidRequestError: If the delete fails
104 | :return: None if the delete is successful
105 | :rtype: None
106 | """
107 | self._connection.delete(path=f"{ApiPath.video}/{self.id}")
108 |
109 | def remove_storage(self) -> None:
110 | """Remove the video storage.
111 |
112 | :raises InvalidRequestError: If the storage removal fails
113 | :return: None if the removal is successful
114 | :rtype: None
115 | """
116 | self._connection.delete(path=f"{ApiPath.video}/{self.id}/{ApiPath.storage}")
117 |
118 | def generate_stream(
119 | self, timeline: Optional[List[Tuple[float, float]]] = None
120 | ) -> str:
121 | """Generate the stream url of the video.
122 |
123 | :param List[Tuple[float, float]] timeline: (optional) The timeline of the video to be streamed in the format [(start, end)]
124 | :raises InvalidRequestError: If the get_stream fails
125 | :return: The stream url of the video
126 | :rtype: str
127 | """
128 | if not timeline and self.stream_url:
129 | return self.stream_url
130 |
131 | stream_data = self._connection.post(
132 | path=f"{ApiPath.video}/{self.id}/{ApiPath.stream}",
133 | data={
134 | "timeline": timeline,
135 | "length": self.length,
136 | },
137 | )
138 | return stream_data.get("stream_url", None)
139 |
140 | def generate_thumbnail(self, time: Optional[float] = None) -> Union[str, Image]:
141 | """Generate the thumbnail of the video.
142 |
143 | :param float time: (optional) The time of the video to generate the thumbnail
144 | :returns: :class:`Image ` object if time is provided else the thumbnail url
145 | :rtype: Union[str, :class:`videodb.image.Image`]
146 | """
147 | if self.thumbnail_url and not time:
148 | return self.thumbnail_url
149 |
150 | if time:
151 | thumbnail_data = self._connection.post(
152 | path=f"{ApiPath.video}/{self.id}/{ApiPath.thumbnail}",
153 | data={
154 | "time": time,
155 | },
156 | )
157 | return Image(self._connection, **thumbnail_data)
158 |
159 | thumbnail_data = self._connection.get(
160 | path=f"{ApiPath.video}/{self.id}/{ApiPath.thumbnail}"
161 | )
162 | self.thumbnail_url = thumbnail_data.get("thumbnail_url")
163 | return self.thumbnail_url
164 |
165 | def get_thumbnails(self) -> List[Image]:
166 | """Get all the thumbnails of the video.
167 |
168 | :return: List of :class:`Image ` objects
169 | :rtype: List[:class:`videodb.image.Image`]
170 | """
171 | thumbnails_data = self._connection.get(
172 | path=f"{ApiPath.video}/{self.id}/{ApiPath.thumbnails}"
173 | )
174 | return [Image(self._connection, **thumbnail) for thumbnail in thumbnails_data]
175 |
176 | def _fetch_transcript(
177 | self,
178 | start: int = None,
179 | end: int = None,
180 | segmenter: str = Segmenter.word,
181 | length: int = 1,
182 | force: bool = None,
183 | ) -> None:
184 | if (
185 | self.transcript
186 | and not start
187 | and not end
188 | and not segmenter
189 | and not length
190 | and not force
191 | ):
192 | return
193 | transcript_data = self._connection.get(
194 | path=f"{ApiPath.video}/{self.id}/{ApiPath.transcription}",
195 | params={
196 | "start": start,
197 | "end": end,
198 | "segmenter": segmenter,
199 | "length": length,
200 | "force": "true" if force else "false",
201 | },
202 | show_progress=True,
203 | )
204 | self.transcript = transcript_data.get("word_timestamps", [])
205 | self.transcript_text = transcript_data.get("text", "")
206 |
207 | def get_transcript(
208 | self,
209 | start: int = None,
210 | end: int = None,
211 | segmenter: Segmenter = Segmenter.word,
212 | length: int = 1,
213 | force: bool = None,
214 | ) -> List[Dict[str, Union[float, str]]]:
215 | """Get timestamped transcript segments for the video.
216 |
217 | :param int start: Start time in seconds
218 | :param int end: End time in seconds
219 | :param Segmenter segmenter: Segmentation type (:class:`Segmenter.word`,
220 | :class:`Segmenter.sentence`, :class:`Segmenter.time`)
221 | :param int length: Length of segments when using time segmenter
222 | :param bool force: Force fetch new transcript
223 | :return: List of dicts with keys: start (float), end (float), text (str)
224 | :rtype: List[Dict[str, Union[float, str]]]
225 | """
226 | self._fetch_transcript(
227 | start=start, end=end, segmenter=segmenter, length=length, force=force
228 | )
229 | return self.transcript
230 |
231 | def get_transcript_text(
232 | self,
233 | start: int = None,
234 | end: int = None,
235 | segmenter: str = Segmenter.word,
236 | length: int = 1,
237 | force: bool = None,
238 | ) -> str:
239 | """Get plain text transcript for the video.
240 |
241 | :param int start: Start time in seconds to get transcript from
242 | :param int end: End time in seconds to get transcript until
243 | :param bool force: Force fetch new transcript
244 | :return: Full transcript text as string
245 | :rtype: str
246 | """
247 | self._fetch_transcript(
248 | start=start, end=end, segmenter=segmenter, length=length, force=force
249 | )
250 | return self.transcript_text
251 |
252 | def translate_transcript(
253 | self,
254 | language: str,
255 | additional_notes: str = "",
256 | callback_url: Optional[str] = None,
257 | ) -> List[dict]:
258 | """Translate transcript of a video to a given language.
259 |
260 | :param str language: Language to translate the transcript
261 | :param str additional_notes: Additional notes for the style of language
262 | :param str callback_url: URL to receive the callback (optional)
263 | :return: List of translated transcript
264 | :rtype: List[dict]
265 | """
266 | translate_data = self._connection.post(
267 | path=f"{ApiPath.collection}/{self.collection_id}/{ApiPath.video}/{self.id}/{ApiPath.translate}",
268 | data={
269 | "language": language,
270 | "additional_notes": additional_notes,
271 | "callback_url": callback_url,
272 | },
273 | )
274 | if translate_data:
275 | return translate_data.get("translated_transcript")
276 |
277 | def index_spoken_words(
278 | self,
279 | language_code: Optional[str] = None,
280 | force: bool = False,
281 | callback_url: str = None,
282 | ) -> None:
283 | """Semantic indexing of spoken words in the video.
284 |
285 | :param str language_code: (optional) Language code of the video
286 | :param bool force: (optional) Force to index the video
287 | :param str callback_url: (optional) URL to receive the callback
288 | :raises InvalidRequestError: If the video is already indexed
289 | :return: None if the indexing is successful
290 | :rtype: None
291 | """
292 | self._connection.post(
293 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}",
294 | data={
295 | "index_type": IndexType.spoken_word,
296 | "language_code": language_code,
297 | "force": force,
298 | "callback_url": callback_url,
299 | },
300 | show_progress=True,
301 | )
302 |
303 | def get_scenes(self) -> Union[list, None]:
304 | """
305 | .. deprecated:: 0.2.0
306 | Use :func:`list_scene_index` and :func:`get_scene_index` instead.
307 |
308 | Get the scenes of the video.
309 |
310 | :return: The scenes of the video
311 | :rtype: list
312 | """
313 | if self.scenes:
314 | return self.scenes
315 | scene_data = self._connection.get(
316 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}",
317 | params={
318 | "index_type": IndexType.scene,
319 | },
320 | )
321 | self.scenes = scene_data
322 | return scene_data if scene_data else None
323 |
324 | def _format_scene_collection(self, scene_collection_data: dict) -> SceneCollection:
325 | scenes = []
326 | for scene in scene_collection_data.get("scenes", []):
327 | frames = []
328 | for frame in scene.get("frames", []):
329 | frame = Frame(
330 | self._connection,
331 | frame.get("frame_id"),
332 | self.id,
333 | scene.get("scene_id"),
334 | frame.get("url"),
335 | frame.get("frame_time"),
336 | frame.get("description"),
337 | )
338 | frames.append(frame)
339 | scene = Scene(
340 | video_id=self.id,
341 | start=scene.get("start"),
342 | end=scene.get("end"),
343 | description=scene.get("description"),
344 | id=scene.get("scene_id"),
345 | frames=frames,
346 | metadata=scene.get("metadata", {}),
347 | connection=self._connection,
348 | )
349 | scenes.append(scene)
350 |
351 | return SceneCollection(
352 | self._connection,
353 | scene_collection_data.get("scene_collection_id"),
354 | self.id,
355 | scene_collection_data.get("config", {}),
356 | scenes,
357 | )
358 |
359 | def extract_scenes(
360 | self,
361 | extraction_type: SceneExtractionType = SceneExtractionType.shot_based,
362 | extraction_config: dict = {},
363 | force: bool = False,
364 | callback_url: str = None,
365 | ) -> Optional[SceneCollection]:
366 | """Extract the scenes of the video.
367 |
368 | :param SceneExtractionType extraction_type: (optional) The type of extraction, :class:`SceneExtractionType ` object
369 | :param dict extraction_config: (optional) Dictionary of configuration parameters to control how scenes are extracted.
370 | For time-based extraction (extraction_type=time_based):\n
371 | - "time" (int, optional): Interval in seconds at which scenes are
372 | segmented. Default is 10 (i.e., every 10 seconds forms a new scene).
373 | - "frame_count" (int, optional): Number of frames to extract per
374 | scene. Default is 1.
375 | - "select_frames" (List[str], optional): Which frames to select from
376 | each segment. Possible values include "first", "middle", and "last".
377 | Default is ["first"].
378 |
379 | For shot-based extraction (extraction_type=shot_based):\n
380 | - "threshold" (int, optional): Sensitivity for detecting scene changes
381 | (camera shots). The higher the threshold, the fewer scene splits.
382 | Default is 20.
383 | - "frame_count" (int, optional): Number of frames to extract from
384 | each detected shot. Default is 1.
385 | :param bool force: (optional) Force to extract the scenes
386 | :param str callback_url: (optional) URL to receive the callback
387 | :raises InvalidRequestError: If the extraction fails
388 | :return: The scene collection, :class:`SceneCollection ` object
389 | :rtype: :class:`videodb.scene.SceneCollection`
390 | """
391 | scenes_data = self._connection.post(
392 | path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}",
393 | data={
394 | "extraction_type": extraction_type,
395 | "extraction_config": extraction_config,
396 | "force": force,
397 | "callback_url": callback_url,
398 | },
399 | )
400 | if not scenes_data:
401 | return None
402 | return self._format_scene_collection(scenes_data.get("scene_collection"))
403 |
404 | def get_scene_collection(self, collection_id: str) -> Optional[SceneCollection]:
405 | """Get the scene collection.
406 |
407 | :param str collection_id: The id of the scene collection
408 | :return: The scene collection
409 | :rtype: :class:`videodb.scene.SceneCollection`
410 | """
411 | if not collection_id:
412 | raise ValueError("collection_id is required")
413 | scenes_data = self._connection.get(
414 | path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}/{collection_id}",
415 | params={"collection_id": self.collection_id},
416 | )
417 | if not scenes_data:
418 | return None
419 | return self._format_scene_collection(scenes_data.get("scene_collection"))
420 |
421 | def list_scene_collection(self):
422 | """List all the scene collections.
423 |
424 | :return: The scene collections
425 | :rtype: list
426 | """
427 | scene_collections_data = self._connection.get(
428 | path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}",
429 | params={"collection_id": self.collection_id},
430 | )
431 | return scene_collections_data.get("scene_collections", [])
432 |
433 | def delete_scene_collection(self, collection_id: str) -> None:
434 | """Delete the scene collection.
435 |
436 | :param str collection_id: The id of the scene collection to be deleted
437 | :raises InvalidRequestError: If the delete fails
438 | :return: None if the delete is successful
439 | :rtype: None
440 | """
441 | if not collection_id:
442 | raise ValueError("collection_id is required")
443 | self._connection.delete(
444 | path=f"{ApiPath.video}/{self.id}/{ApiPath.scenes}/{collection_id}"
445 | )
446 |
447 | def index_scenes(
448 | self,
449 | extraction_type: SceneExtractionType = SceneExtractionType.shot_based,
450 | extraction_config: Dict = {},
451 | prompt: Optional[str] = None,
452 | metadata: Dict = {},
453 | model_name: Optional[str] = None,
454 | model_config: Optional[Dict] = None,
455 | name: Optional[str] = None,
456 | scenes: Optional[List[Scene]] = None,
457 | callback_url: Optional[str] = None,
458 | ) -> Optional[str]:
459 | """Index the scenes of the video.
460 |
461 | :param SceneExtractionType extraction_type: (optional) The type of extraction, :class:`SceneExtractionType ` object
462 | :param dict extraction_config: (optional) Dictionary of configuration parameters to control how scenes are extracted.
463 | For time-based extraction (extraction_type=time_based):\n
464 | - "time" (int, optional): Interval in seconds at which scenes are
465 | segmented. Default is 10 (i.e., every 10 seconds forms a new scene).
466 | - "frame_count" (int, optional): Number of frames to extract per
467 | scene. Default is 1.
468 | - "select_frames" (List[str], optional): Which frames to select from
469 | each segment. Possible values include "first", "middle", and "last".
470 | Default is ["first"].
471 |
472 | For shot-based extraction (extraction_type=shot_based):\n
473 | - "threshold" (int, optional): Sensitivity for detecting scene changes
474 | (camera shots). The higher the threshold, the fewer scene splits.
475 | Default is 20.
476 | - "frame_count" (int, optional): Number of frames to extract from
477 | each detected shot. Default is 1.
478 | :param str prompt: (optional) The prompt for the extraction
479 | :param str model_name: (optional) The model name for the extraction
480 | :param dict model_config: (optional) The model configuration for the extraction
481 | :param str name: (optional) The name of the scene index
482 | :param list[Scene] scenes: (optional) The scenes to be indexed, List of :class:`Scene ` objects
483 | :param str callback_url: (optional) The callback url
484 | :raises InvalidRequestError: If the index fails or index already exists
485 | :return: The scene index id
486 | :rtype: str
487 | """
488 | scenes_data = self._connection.post(
489 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}",
490 | data={
491 | "extraction_type": extraction_type,
492 | "extraction_config": extraction_config,
493 | "prompt": prompt,
494 | "metadata": metadata,
495 | "model_name": model_name,
496 | "model_config": model_config,
497 | "name": name,
498 | "scenes": [scene.to_json() for scene in scenes] if scenes else None,
499 | "callback_url": callback_url,
500 | },
501 | )
502 | if not scenes_data:
503 | return None
504 | return scenes_data.get("scene_index_id")
505 |
506 | def list_scene_index(self) -> List:
507 | """List all the scene indexes.
508 |
509 | :return: The scene indexes
510 | :rtype: list
511 | """
512 | index_data = self._connection.get(
513 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}",
514 | params={"collection_id": self.collection_id},
515 | )
516 | return index_data.get("scene_indexes", [])
517 |
518 | def get_scene_index(self, scene_index_id: str) -> Optional[List]:
519 | """Get the scene index.
520 |
521 | :param str scene_index_id: The id of the scene index
522 | :return: The scene index records
523 | :rtype: list
524 | """
525 | index_data = self._connection.get(
526 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}/{scene_index_id}",
527 | params={"collection_id": self.collection_id},
528 | )
529 | if not index_data:
530 | return None
531 | return index_data.get("scene_index_records", [])
532 |
533 | def delete_scene_index(self, scene_index_id: str) -> None:
534 | """Delete the scene index.
535 |
536 | :param str scene_index_id: The id of the scene index to be deleted
537 | :raises InvalidRequestError: If the delete fails
538 | :return: None if the delete is successful
539 | :rtype: None
540 | """
541 | if not scene_index_id:
542 | raise ValueError("scene_index_id is required")
543 | self._connection.delete(
544 | path=f"{ApiPath.video}/{self.id}/{ApiPath.index}/{ApiPath.scene}/{scene_index_id}"
545 | )
546 |
547 | def add_subtitle(self, style: SubtitleStyle = SubtitleStyle()) -> str:
548 | """Add subtitles to the video.
549 |
550 | :param SubtitleStyle style: (optional) The style of the subtitles, :class:`SubtitleStyle ` object
551 | :return: The stream url of the video with subtitles
552 | :rtype: str
553 | """
554 | if not isinstance(style, SubtitleStyle):
555 | raise ValueError("style must be of type SubtitleStyle")
556 | subtitle_data = self._connection.post(
557 | path=f"{ApiPath.video}/{self.id}/{ApiPath.workflow}",
558 | data={
559 | "type": Workflows.add_subtitles,
560 | "subtitle_style": style.__dict__,
561 | },
562 | )
563 | return subtitle_data.get("stream_url", None)
564 |
565 | def insert_video(self, video, timestamp: float) -> str:
566 | """Insert a video into another video
567 |
568 | :param Video video: The video to be inserted
569 | :param float timestamp: The timestamp where the video should be inserted
570 | :raises InvalidRequestError: If the insert fails
571 | :return: The stream url of the inserted video
572 | :rtype: str
573 | """
574 | if timestamp > float(self.length):
575 | timestamp = float(self.length)
576 |
577 | pre_shot = Shot(self._connection, self.id, timestamp, "", 0, timestamp)
578 | inserted_shot = Shot(
579 | self._connection, video.id, video.length, "", 0, video.length
580 | )
581 | post_shot = Shot(
582 | self._connection,
583 | self.id,
584 | self.length - timestamp,
585 | "",
586 | timestamp,
587 | self.length,
588 | )
589 | all_shots = [pre_shot, inserted_shot, post_shot]
590 |
591 | compile_data = self._connection.post(
592 | path=f"{ApiPath.compile}",
593 | data=[
594 | {
595 | "video_id": shot.video_id,
596 | "collection_id": self.collection_id,
597 | "shots": [(float(shot.start), float(shot.end))],
598 | }
599 | for shot in all_shots
600 | ],
601 | )
602 | return compile_data.get("stream_url", None)
603 |
604 | def play(self) -> str:
605 | """Open the player url in the browser/iframe and return the stream url.
606 |
607 | :return: The player url
608 | :rtype: str
609 | """
610 | return play_stream(self.stream_url)
611 |
--------------------------------------------------------------------------------