videopiper 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- videopiper-0.1.0/LICENSE +21 -0
- videopiper-0.1.0/PKG-INFO +226 -0
- videopiper-0.1.0/README.md +205 -0
- videopiper-0.1.0/pyproject.toml +33 -0
- videopiper-0.1.0/setup.cfg +4 -0
- videopiper-0.1.0/tests/test_client.py +251 -0
- videopiper-0.1.0/tests/test_package.py +29 -0
- videopiper-0.1.0/tests/test_transport.py +92 -0
- videopiper-0.1.0/tests/test_uploads.py +141 -0
- videopiper-0.1.0/tests/test_wait.py +53 -0
- videopiper-0.1.0/tests/test_webhooks.py +108 -0
- videopiper-0.1.0/videopiper/__init__.py +16 -0
- videopiper-0.1.0/videopiper/_http.py +207 -0
- videopiper-0.1.0/videopiper/_version.py +1 -0
- videopiper-0.1.0/videopiper/client.py +72 -0
- videopiper-0.1.0/videopiper/errors.py +51 -0
- videopiper-0.1.0/videopiper/py.typed +0 -0
- videopiper-0.1.0/videopiper/resources.py +457 -0
- videopiper-0.1.0/videopiper/types.py +165 -0
- videopiper-0.1.0/videopiper/webhooks.py +55 -0
- videopiper-0.1.0/videopiper.egg-info/PKG-INFO +226 -0
- videopiper-0.1.0/videopiper.egg-info/SOURCES.txt +22 -0
- videopiper-0.1.0/videopiper.egg-info/dependency_links.txt +1 -0
- videopiper-0.1.0/videopiper.egg-info/top_level.txt +1 -0
videopiper-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PodWorks3D LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: videopiper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Video Piper API: upload, import, edit and stream videos, with webhook verification.
|
|
5
|
+
Author: PodWorks3D LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://videopiper.com/docs/
|
|
8
|
+
Keywords: video,api,sdk,video-api,transcoding,hls,webhooks,live-streaming
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# videopiper
|
|
23
|
+
|
|
24
|
+
The official Python SDK for the [Video Piper](https://videopiper.com/) API: upload or import videos, cut and join them, stream live, and check webhooks. Python 3.9 or newer, no dependencies (it uses the standard library), type hints included.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install videopiper
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Keep your API key on your server. To let a browser upload, record or edit, make a short-lived token with the SDK and give the page that (see [Tokens for browsers](#tokens-for-browsers)).
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Upload a file, wait for it to process, and get the download link:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from videopiper import VideoPiper
|
|
38
|
+
|
|
39
|
+
client = VideoPiper() # reads VIDEO_PIPER_API_KEY
|
|
40
|
+
|
|
41
|
+
upload = client.videos.upload_file("lesson.mp4", title="Lesson 1")
|
|
42
|
+
video = client.videos.wait_until_done(upload["videoId"])
|
|
43
|
+
|
|
44
|
+
if video["status"] == "SUCCEEDED":
|
|
45
|
+
print(video["downloadUrl"]) # an MP4 link, valid for 15 minutes
|
|
46
|
+
else:
|
|
47
|
+
print("Processing ended with", video["status"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`upload_file` creates the video, then for each part asks for its upload URL just before sending that part (the URLs expire after 15 minutes), sends the bytes with no `Authorization` header, and completes the upload. Any file size works; parts go one at a time and only one part is held in memory.
|
|
51
|
+
|
|
52
|
+
## Setup
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
client = VideoPiper(
|
|
56
|
+
api_key="vpk_...", # default: the VIDEO_PIPER_API_KEY environment variable
|
|
57
|
+
base_url="https://api.videopiper.dev/v1", # default: VIDEO_PIPER_API_URL, then this
|
|
58
|
+
timeout=60, # seconds, per request
|
|
59
|
+
max_retries=3, # see Retries
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Other options: `upload_timeout` (per part, default 10 minutes), `retry_base_delay`, `max_retry_delay`, `requests_per_second` (default 5, the API's limit per key; 0 turns it off) and `transport` (an object with `send(method, url, headers, body, timeout)`, to use instead of `urllib`).
|
|
64
|
+
|
|
65
|
+
Arguments are Python names (`file_name`, `max_height`) and are sent with the API's camelCase names. Objects inside a request (`trim`, `redactions`, `clips`, `metadata`) are sent exactly as the [API docs](https://videopiper.com/docs/) show them. Responses are plain dicts with the API's keys; `videopiper.types` has `TypedDict`s for the main ones.
|
|
66
|
+
|
|
67
|
+
## Videos
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Upload from disk, from bytes, or from a file-like object
|
|
71
|
+
client.videos.upload_file("lesson.mp4", max_height=720, captions=True, on_progress=print)
|
|
72
|
+
client.videos.upload_bytes(data, "clip.mp4", metadata={"lessonId": "123"})
|
|
73
|
+
client.videos.upload_stream(stream, size_bytes, "recording.webm")
|
|
74
|
+
|
|
75
|
+
# Have the API download a public HTTPS URL
|
|
76
|
+
video = client.videos.import_url("https://example.com/tutorial.mp4", max_height=1080)
|
|
77
|
+
|
|
78
|
+
# Status, and the links once it has finished
|
|
79
|
+
status = client.videos.get(video["videoId"]) # playbackUrl, streamUrl, downloadUrl, posterUrl
|
|
80
|
+
done = client.videos.wait_until_done(video["videoId"], timeout=600, on_status=lambda s: print(s["status"]))
|
|
81
|
+
|
|
82
|
+
# Lists: one page, or every video
|
|
83
|
+
page = client.videos.list(limit=25, metadata={"lessonId": "123"})
|
|
84
|
+
for item in client.videos.list_all(limit=100):
|
|
85
|
+
print(item["videoId"], item.get("outcome"))
|
|
86
|
+
|
|
87
|
+
client.videos.update(video_id, title="New title", metadata={"archived": None}) # None clears
|
|
88
|
+
client.videos.reprocess(video_id, max_height=2160, trim={"start": 30}) # a new video from the same upload
|
|
89
|
+
client.videos.cancel(video_id) # stop processing
|
|
90
|
+
client.videos.delete(video_id)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`wait_until_done` checks the status with a growing pause (3 s up to 15 s) until the video reaches a final status and returns it; `FAILED`, `TIMED_OUT` and `ABORTED` are returned, not raised, so look at `["status"]`. It raises `WaitTimeoutError` after `timeout` seconds (default 30 minutes). In production prefer [webhooks](#webhooks) to polling.
|
|
94
|
+
|
|
95
|
+
The steps behind `upload_file` are public too, for streaming uploads and other special cases: `create`, `get_part_urls`, `complete`, `abort` and `heartbeat`.
|
|
96
|
+
|
|
97
|
+
Also on `client.videos`: `list_thumbnails`, `choose_thumbnail`, `get_chapters`, `set_chapters`, `delete_chapters` and `set_embed`.
|
|
98
|
+
|
|
99
|
+
## Edits
|
|
100
|
+
|
|
101
|
+
Make a new video from clips of finished videos: trim, stitch and splice.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
edit = client.edits.create(
|
|
105
|
+
[
|
|
106
|
+
{"videoId": intro_id, "end": 60},
|
|
107
|
+
{"videoId": lesson_id, "start": 90, "transition": {"type": "fade", "seconds": 0.75}},
|
|
108
|
+
],
|
|
109
|
+
file_name="Lesson 1 (final)",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Dry runs and idempotency
|
|
114
|
+
|
|
115
|
+
`dry_run=True` checks a request without creating or starting anything (it doesn't count toward your daily limit) and returns `{"valid": True, "dryRun": True, ...}`; an edit also reports its length. It works on `videos.create`, `import_url`, `complete`, `reprocess`, `edits.create` and `live_streams.create`.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
check = client.edits.create(clips, dry_run=True)
|
|
119
|
+
print(check["durationSeconds"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
An `idempotency_key` makes a retry safe: the same key with the same request returns the first response again (kept 24 hours) instead of creating a second video.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
client.videos.import_url(url, idempotency_key=f"import-{lesson_id}")
|
|
126
|
+
client.videos.upload_file(path, idempotency_key=f"upload-{lesson_id}") # complete uses "<key>:complete"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tokens for browsers
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
upload = client.upload_tokens.create(max_seconds=600, metadata={"userId": "u_1"})
|
|
133
|
+
# <video-piper-recorder upload-token="{upload['uploadToken']}">
|
|
134
|
+
edit = client.edit_tokens.create([a, b], max_seconds=300)
|
|
135
|
+
thumb = client.thumbnail_tokens.create(video_id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
These go with the web components in [`@videopiper/elements`](https://www.npmjs.com/package/@videopiper/elements).
|
|
139
|
+
|
|
140
|
+
## Usage and account
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
usage = client.usage.get("2026-10") # processing, storage and delivery against the plan
|
|
144
|
+
account = client.account.get()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Live streams (Pro and Scale)
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
stream = client.live_streams.create(title="Product demo", record=True, max_duration_minutes=60)
|
|
151
|
+
print(stream["ingest"]["rtmpsUrl"], stream["ingest"]["streamKey"]) # for OBS: give the key only to the broadcaster
|
|
152
|
+
print(stream["watch"]["url"]) # for viewers
|
|
153
|
+
client.live_streams.end(stream["liveStreamId"]) # the recording becomes a normal video
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Also `get`, `list`, `list_all`, `update`, `get_ingest` and `delete`.
|
|
157
|
+
|
|
158
|
+
## Webhooks
|
|
159
|
+
|
|
160
|
+
Set the endpoint, and keep the signing secret it returns:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
config = client.webhooks.set("https://example.com/hooks/video-piper", events=["video.ready", "video.failed"])
|
|
164
|
+
# config["secret"] is "whsec_..."
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Check every delivery with `verify_webhook` on the **raw** body (before JSON parsing). It returns the parsed event, or raises `WebhookVerificationError` if a header is missing, no signature matches (it accepts several, separated by spaces), or the timestamp is more than 5 minutes off (change it with `tolerance_seconds`).
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from flask import Flask, request
|
|
171
|
+
from videopiper import WebhookVerificationError, verify_webhook
|
|
172
|
+
|
|
173
|
+
app = Flask(__name__)
|
|
174
|
+
|
|
175
|
+
@app.post("/hooks/video-piper")
|
|
176
|
+
def video_piper_hook():
|
|
177
|
+
try:
|
|
178
|
+
event = verify_webhook(WEBHOOK_SECRET, request.get_data(), request.headers)
|
|
179
|
+
except WebhookVerificationError:
|
|
180
|
+
return "", 401
|
|
181
|
+
if event["type"] == "video.ready":
|
|
182
|
+
print("ready", event["data"]["videoId"])
|
|
183
|
+
return "", 204
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Deliveries are at-least-once: deduplicate on `event["id"]` (the `webhook-id` header). Also on `client.webhooks`: `get`, `delete`, `rotate_secret`, `test` and `deliveries`.
|
|
187
|
+
|
|
188
|
+
## Errors
|
|
189
|
+
|
|
190
|
+
Every failed call raises `VideoPiperError`:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from videopiper import VideoPiperError
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
client.videos.upload_file("lesson.mp4")
|
|
197
|
+
except VideoPiperError as error:
|
|
198
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
199
|
+
if error.code == "PLAN_LIMIT_REACHED":
|
|
200
|
+
... # out of Free minutes
|
|
201
|
+
for problem in error.details or []: # INVALID_REQUEST lists every problem
|
|
202
|
+
print(problem["field"], problem["message"])
|
|
203
|
+
raise
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Program against `code` (`PLAN_LIMIT_REACHED`, `DAILY_LIMIT`, `RATE_LIMITED`, `NOT_FOUND`, ...), not the message. Quote `request_id` when asking for help. A request that got no answer raises `VideoPiperConnectionError` (`status` 0, `code` `CONNECTION_ERROR`); `wait_until_done` running out of time raises `WaitTimeoutError`. All three are `VideoPiperError`s.
|
|
207
|
+
|
|
208
|
+
## Retries and rate limits
|
|
209
|
+
|
|
210
|
+
Failed requests are sent again, up to `max_retries` times with exponential backoff and jitter, when it is safe: network errors, `RATE_LIMITED` (429) and 500, 502, 503 or 504, on `GET`, `PUT`, `PATCH` and `DELETE`, on the idempotent `POST`s (part URLs, `complete`, `cancel`, `abort`) and on any `POST` that carries an `idempotency_key`. Daily, quota and plan limits are not retried. A `Retry-After` header is honoured (a wait longer than `max_retry_delay`, 60 s by default, raises instead).
|
|
211
|
+
|
|
212
|
+
The client sends at most 5 requests a second (the API's limit per key); lower or raise it with `requests_per_second`.
|
|
213
|
+
|
|
214
|
+
## Test mode
|
|
215
|
+
|
|
216
|
+
A test key (`vp_test_...`, made in the app under Account, API keys, Create test key) works the same way and is free: it only sees test videos, which are one MP4 of at most 60 seconds and 480p, marked TEST, and deleted after 24 hours. Use it in tests and CI. A test key can't read the account or change the webhook, and `usage.get` is always empty for it.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
client = VideoPiper(os.environ["VIDEO_PIPER_TEST_KEY"])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Test webhook events carry `livemode: false`.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT. Docs: <https://videopiper.com/docs/>.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# videopiper
|
|
2
|
+
|
|
3
|
+
The official Python SDK for the [Video Piper](https://videopiper.com/) API: upload or import videos, cut and join them, stream live, and check webhooks. Python 3.9 or newer, no dependencies (it uses the standard library), type hints included.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install videopiper
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Keep your API key on your server. To let a browser upload, record or edit, make a short-lived token with the SDK and give the page that (see [Tokens for browsers](#tokens-for-browsers)).
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
Upload a file, wait for it to process, and get the download link:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from videopiper import VideoPiper
|
|
17
|
+
|
|
18
|
+
client = VideoPiper() # reads VIDEO_PIPER_API_KEY
|
|
19
|
+
|
|
20
|
+
upload = client.videos.upload_file("lesson.mp4", title="Lesson 1")
|
|
21
|
+
video = client.videos.wait_until_done(upload["videoId"])
|
|
22
|
+
|
|
23
|
+
if video["status"] == "SUCCEEDED":
|
|
24
|
+
print(video["downloadUrl"]) # an MP4 link, valid for 15 minutes
|
|
25
|
+
else:
|
|
26
|
+
print("Processing ended with", video["status"])
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`upload_file` creates the video, then for each part asks for its upload URL just before sending that part (the URLs expire after 15 minutes), sends the bytes with no `Authorization` header, and completes the upload. Any file size works; parts go one at a time and only one part is held in memory.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
client = VideoPiper(
|
|
35
|
+
api_key="vpk_...", # default: the VIDEO_PIPER_API_KEY environment variable
|
|
36
|
+
base_url="https://api.videopiper.dev/v1", # default: VIDEO_PIPER_API_URL, then this
|
|
37
|
+
timeout=60, # seconds, per request
|
|
38
|
+
max_retries=3, # see Retries
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Other options: `upload_timeout` (per part, default 10 minutes), `retry_base_delay`, `max_retry_delay`, `requests_per_second` (default 5, the API's limit per key; 0 turns it off) and `transport` (an object with `send(method, url, headers, body, timeout)`, to use instead of `urllib`).
|
|
43
|
+
|
|
44
|
+
Arguments are Python names (`file_name`, `max_height`) and are sent with the API's camelCase names. Objects inside a request (`trim`, `redactions`, `clips`, `metadata`) are sent exactly as the [API docs](https://videopiper.com/docs/) show them. Responses are plain dicts with the API's keys; `videopiper.types` has `TypedDict`s for the main ones.
|
|
45
|
+
|
|
46
|
+
## Videos
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Upload from disk, from bytes, or from a file-like object
|
|
50
|
+
client.videos.upload_file("lesson.mp4", max_height=720, captions=True, on_progress=print)
|
|
51
|
+
client.videos.upload_bytes(data, "clip.mp4", metadata={"lessonId": "123"})
|
|
52
|
+
client.videos.upload_stream(stream, size_bytes, "recording.webm")
|
|
53
|
+
|
|
54
|
+
# Have the API download a public HTTPS URL
|
|
55
|
+
video = client.videos.import_url("https://example.com/tutorial.mp4", max_height=1080)
|
|
56
|
+
|
|
57
|
+
# Status, and the links once it has finished
|
|
58
|
+
status = client.videos.get(video["videoId"]) # playbackUrl, streamUrl, downloadUrl, posterUrl
|
|
59
|
+
done = client.videos.wait_until_done(video["videoId"], timeout=600, on_status=lambda s: print(s["status"]))
|
|
60
|
+
|
|
61
|
+
# Lists: one page, or every video
|
|
62
|
+
page = client.videos.list(limit=25, metadata={"lessonId": "123"})
|
|
63
|
+
for item in client.videos.list_all(limit=100):
|
|
64
|
+
print(item["videoId"], item.get("outcome"))
|
|
65
|
+
|
|
66
|
+
client.videos.update(video_id, title="New title", metadata={"archived": None}) # None clears
|
|
67
|
+
client.videos.reprocess(video_id, max_height=2160, trim={"start": 30}) # a new video from the same upload
|
|
68
|
+
client.videos.cancel(video_id) # stop processing
|
|
69
|
+
client.videos.delete(video_id)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`wait_until_done` checks the status with a growing pause (3 s up to 15 s) until the video reaches a final status and returns it; `FAILED`, `TIMED_OUT` and `ABORTED` are returned, not raised, so look at `["status"]`. It raises `WaitTimeoutError` after `timeout` seconds (default 30 minutes). In production prefer [webhooks](#webhooks) to polling.
|
|
73
|
+
|
|
74
|
+
The steps behind `upload_file` are public too, for streaming uploads and other special cases: `create`, `get_part_urls`, `complete`, `abort` and `heartbeat`.
|
|
75
|
+
|
|
76
|
+
Also on `client.videos`: `list_thumbnails`, `choose_thumbnail`, `get_chapters`, `set_chapters`, `delete_chapters` and `set_embed`.
|
|
77
|
+
|
|
78
|
+
## Edits
|
|
79
|
+
|
|
80
|
+
Make a new video from clips of finished videos: trim, stitch and splice.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
edit = client.edits.create(
|
|
84
|
+
[
|
|
85
|
+
{"videoId": intro_id, "end": 60},
|
|
86
|
+
{"videoId": lesson_id, "start": 90, "transition": {"type": "fade", "seconds": 0.75}},
|
|
87
|
+
],
|
|
88
|
+
file_name="Lesson 1 (final)",
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Dry runs and idempotency
|
|
93
|
+
|
|
94
|
+
`dry_run=True` checks a request without creating or starting anything (it doesn't count toward your daily limit) and returns `{"valid": True, "dryRun": True, ...}`; an edit also reports its length. It works on `videos.create`, `import_url`, `complete`, `reprocess`, `edits.create` and `live_streams.create`.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
check = client.edits.create(clips, dry_run=True)
|
|
98
|
+
print(check["durationSeconds"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
An `idempotency_key` makes a retry safe: the same key with the same request returns the first response again (kept 24 hours) instead of creating a second video.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
client.videos.import_url(url, idempotency_key=f"import-{lesson_id}")
|
|
105
|
+
client.videos.upload_file(path, idempotency_key=f"upload-{lesson_id}") # complete uses "<key>:complete"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Tokens for browsers
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
upload = client.upload_tokens.create(max_seconds=600, metadata={"userId": "u_1"})
|
|
112
|
+
# <video-piper-recorder upload-token="{upload['uploadToken']}">
|
|
113
|
+
edit = client.edit_tokens.create([a, b], max_seconds=300)
|
|
114
|
+
thumb = client.thumbnail_tokens.create(video_id)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
These go with the web components in [`@videopiper/elements`](https://www.npmjs.com/package/@videopiper/elements).
|
|
118
|
+
|
|
119
|
+
## Usage and account
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
usage = client.usage.get("2026-10") # processing, storage and delivery against the plan
|
|
123
|
+
account = client.account.get()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Live streams (Pro and Scale)
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
stream = client.live_streams.create(title="Product demo", record=True, max_duration_minutes=60)
|
|
130
|
+
print(stream["ingest"]["rtmpsUrl"], stream["ingest"]["streamKey"]) # for OBS: give the key only to the broadcaster
|
|
131
|
+
print(stream["watch"]["url"]) # for viewers
|
|
132
|
+
client.live_streams.end(stream["liveStreamId"]) # the recording becomes a normal video
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Also `get`, `list`, `list_all`, `update`, `get_ingest` and `delete`.
|
|
136
|
+
|
|
137
|
+
## Webhooks
|
|
138
|
+
|
|
139
|
+
Set the endpoint, and keep the signing secret it returns:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
config = client.webhooks.set("https://example.com/hooks/video-piper", events=["video.ready", "video.failed"])
|
|
143
|
+
# config["secret"] is "whsec_..."
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Check every delivery with `verify_webhook` on the **raw** body (before JSON parsing). It returns the parsed event, or raises `WebhookVerificationError` if a header is missing, no signature matches (it accepts several, separated by spaces), or the timestamp is more than 5 minutes off (change it with `tolerance_seconds`).
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from flask import Flask, request
|
|
150
|
+
from videopiper import WebhookVerificationError, verify_webhook
|
|
151
|
+
|
|
152
|
+
app = Flask(__name__)
|
|
153
|
+
|
|
154
|
+
@app.post("/hooks/video-piper")
|
|
155
|
+
def video_piper_hook():
|
|
156
|
+
try:
|
|
157
|
+
event = verify_webhook(WEBHOOK_SECRET, request.get_data(), request.headers)
|
|
158
|
+
except WebhookVerificationError:
|
|
159
|
+
return "", 401
|
|
160
|
+
if event["type"] == "video.ready":
|
|
161
|
+
print("ready", event["data"]["videoId"])
|
|
162
|
+
return "", 204
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Deliveries are at-least-once: deduplicate on `event["id"]` (the `webhook-id` header). Also on `client.webhooks`: `get`, `delete`, `rotate_secret`, `test` and `deliveries`.
|
|
166
|
+
|
|
167
|
+
## Errors
|
|
168
|
+
|
|
169
|
+
Every failed call raises `VideoPiperError`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from videopiper import VideoPiperError
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
client.videos.upload_file("lesson.mp4")
|
|
176
|
+
except VideoPiperError as error:
|
|
177
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
178
|
+
if error.code == "PLAN_LIMIT_REACHED":
|
|
179
|
+
... # out of Free minutes
|
|
180
|
+
for problem in error.details or []: # INVALID_REQUEST lists every problem
|
|
181
|
+
print(problem["field"], problem["message"])
|
|
182
|
+
raise
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Program against `code` (`PLAN_LIMIT_REACHED`, `DAILY_LIMIT`, `RATE_LIMITED`, `NOT_FOUND`, ...), not the message. Quote `request_id` when asking for help. A request that got no answer raises `VideoPiperConnectionError` (`status` 0, `code` `CONNECTION_ERROR`); `wait_until_done` running out of time raises `WaitTimeoutError`. All three are `VideoPiperError`s.
|
|
186
|
+
|
|
187
|
+
## Retries and rate limits
|
|
188
|
+
|
|
189
|
+
Failed requests are sent again, up to `max_retries` times with exponential backoff and jitter, when it is safe: network errors, `RATE_LIMITED` (429) and 500, 502, 503 or 504, on `GET`, `PUT`, `PATCH` and `DELETE`, on the idempotent `POST`s (part URLs, `complete`, `cancel`, `abort`) and on any `POST` that carries an `idempotency_key`. Daily, quota and plan limits are not retried. A `Retry-After` header is honoured (a wait longer than `max_retry_delay`, 60 s by default, raises instead).
|
|
190
|
+
|
|
191
|
+
The client sends at most 5 requests a second (the API's limit per key); lower or raise it with `requests_per_second`.
|
|
192
|
+
|
|
193
|
+
## Test mode
|
|
194
|
+
|
|
195
|
+
A test key (`vp_test_...`, made in the app under Account, API keys, Create test key) works the same way and is free: it only sees test videos, which are one MP4 of at most 60 seconds and 480p, marked TEST, and deleted after 24 hours. Use it in tests and CI. A test key can't read the account or change the webhook, and `usage.get` is always empty for it.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
client = VideoPiper(os.environ["VIDEO_PIPER_TEST_KEY"])
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Test webhook events carry `livemode: false`.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT. Docs: <https://videopiper.com/docs/>.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "videopiper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Video Piper API: upload, import, edit and stream videos, with webhook verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "PodWorks3D LLC" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["video", "api", "sdk", "video-api", "transcoding", "hls", "webhooks", "live-streaming"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://videopiper.com/docs/"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["videopiper"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
videopiper = ["py.typed"]
|