magic_hour 0.40.0__py3-none-any.whl → 0.44.0__py3-none-any.whl
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.
- magic_hour/README.md +2 -3
- magic_hour/environment.py +1 -1
- magic_hour/helpers/download.py +2 -0
- magic_hour/resources/v1/README.md +2 -3
- magic_hour/resources/v1/ai_clothes_changer/README.md +13 -14
- magic_hour/resources/v1/ai_face_editor/README.md +26 -27
- magic_hour/resources/v1/ai_gif_generator/README.md +12 -13
- magic_hour/resources/v1/ai_gif_generator/client.py +2 -2
- magic_hour/resources/v1/ai_headshot_generator/README.md +13 -14
- magic_hour/resources/v1/ai_headshot_generator/client.py +2 -2
- magic_hour/resources/v1/ai_image_editor/README.md +24 -17
- magic_hour/resources/v1/ai_image_editor/client.py +40 -10
- magic_hour/resources/v1/ai_image_generator/README.md +26 -18
- magic_hour/resources/v1/ai_image_generator/client.py +14 -6
- magic_hour/resources/v1/ai_image_upscaler/README.md +14 -15
- magic_hour/resources/v1/ai_meme_generator/README.md +12 -13
- magic_hour/resources/v1/ai_photo_editor/README.md +22 -23
- magic_hour/resources/v1/ai_qr_code_generator/README.md +13 -14
- magic_hour/resources/v1/ai_qr_code_generator/client.py +4 -4
- magic_hour/resources/v1/ai_talking_photo/README.md +16 -17
- magic_hour/resources/v1/ai_voice_cloner/README.md +62 -0
- magic_hour/resources/v1/ai_voice_cloner/__init__.py +4 -0
- magic_hour/resources/v1/ai_voice_cloner/client.py +272 -0
- magic_hour/resources/v1/ai_voice_generator/README.md +66 -10
- magic_hour/resources/v1/ai_voice_generator/client.py +122 -0
- magic_hour/resources/v1/animation/README.md +24 -25
- magic_hour/resources/v1/audio_projects/README.md +58 -13
- magic_hour/resources/v1/audio_projects/__init__.py +10 -2
- magic_hour/resources/v1/audio_projects/client.py +137 -0
- magic_hour/resources/v1/audio_projects/client_test.py +520 -0
- magic_hour/resources/v1/auto_subtitle_generator/README.md +15 -16
- magic_hour/resources/v1/client.py +6 -0
- magic_hour/resources/v1/face_detection/README.md +21 -20
- magic_hour/resources/v1/face_swap/README.md +23 -25
- magic_hour/resources/v1/face_swap/client.py +2 -2
- magic_hour/resources/v1/face_swap_photo/README.md +13 -14
- magic_hour/resources/v1/files/README.md +1 -5
- magic_hour/resources/v1/files/upload_urls/README.md +11 -10
- magic_hour/resources/v1/files/upload_urls/client.py +6 -4
- magic_hour/resources/v1/image_background_remover/README.md +11 -12
- magic_hour/resources/v1/image_projects/README.md +12 -16
- magic_hour/resources/v1/image_to_video/README.md +19 -21
- magic_hour/resources/v1/lip_sync/README.md +27 -21
- magic_hour/resources/v1/lip_sync/client.py +15 -0
- magic_hour/resources/v1/photo_colorizer/README.md +10 -11
- magic_hour/resources/v1/text_to_video/README.md +15 -17
- magic_hour/resources/v1/video_projects/README.md +12 -16
- magic_hour/resources/v1/video_to_video/README.md +24 -26
- magic_hour/types/models/__init__.py +2 -0
- magic_hour/types/models/v1_ai_voice_cloner_create_response.py +27 -0
- magic_hour/types/models/v1_audio_projects_get_response.py +1 -1
- magic_hour/types/models/v1_video_projects_get_response.py +1 -1
- magic_hour/types/params/__init__.py +26 -0
- magic_hour/types/params/v1_ai_image_editor_create_body_assets.py +18 -4
- magic_hour/types/params/v1_ai_image_editor_create_body_style.py +13 -0
- magic_hour/types/params/v1_ai_image_editor_generate_body_assets.py +12 -1
- magic_hour/types/params/v1_ai_image_generator_create_body_style.py +16 -0
- magic_hour/types/params/v1_ai_talking_photo_create_body_style.py +6 -4
- magic_hour/types/params/v1_ai_voice_cloner_create_body.py +49 -0
- magic_hour/types/params/v1_ai_voice_cloner_create_body_assets.py +33 -0
- magic_hour/types/params/v1_ai_voice_cloner_create_body_style.py +28 -0
- magic_hour/types/params/v1_ai_voice_cloner_generate_body_assets.py +28 -0
- magic_hour/types/params/v1_ai_voice_generator_create_body_style.py +382 -2
- magic_hour/types/params/v1_face_swap_create_body_style.py +1 -1
- magic_hour/types/params/v1_files_upload_urls_create_body_items_item.py +1 -1
- magic_hour/types/params/v1_lip_sync_create_body.py +12 -0
- magic_hour/types/params/v1_lip_sync_create_body_style.py +37 -0
- magic_hour/types/params/v1_video_to_video_create_body.py +1 -1
- magic_hour/types/params/v1_video_to_video_create_body_style.py +32 -4
- {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/METADATA +77 -62
- {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/RECORD +73 -63
- {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/LICENSE +0 -0
- {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import pytest
|
|
3
|
+
import httpx
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Generator, Literal, Union, List
|
|
6
|
+
from unittest.mock import Mock, AsyncMock
|
|
7
|
+
|
|
8
|
+
from magic_hour.types import models
|
|
9
|
+
from magic_hour.resources.v1.audio_projects.client import (
|
|
10
|
+
AudioProjectsClient,
|
|
11
|
+
AsyncAudioProjectsClient,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DummyResponse(models.V1AudioProjectsGetResponse):
|
|
16
|
+
"""Helper response with defaults"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
status: Literal[
|
|
22
|
+
"complete", "queued", "rendering", "error", "canceled"
|
|
23
|
+
] = "complete",
|
|
24
|
+
download_url: Union[str, None] = None,
|
|
25
|
+
error: Union[str, None] = None,
|
|
26
|
+
):
|
|
27
|
+
# Create error object if error string is provided
|
|
28
|
+
error_obj = None
|
|
29
|
+
if error:
|
|
30
|
+
error_obj = models.V1AudioProjectsGetResponseError(
|
|
31
|
+
code="TEST_ERROR", message=error
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
super().__init__(
|
|
35
|
+
id="test-id",
|
|
36
|
+
created_at=datetime.datetime.now().isoformat(),
|
|
37
|
+
credits_charged=0,
|
|
38
|
+
downloads=[
|
|
39
|
+
models.V1AudioProjectsGetResponseDownloadsItem(
|
|
40
|
+
url=download_url, expires_at="2024-01-01T00:00:00Z"
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
if download_url
|
|
44
|
+
else [],
|
|
45
|
+
enabled=True,
|
|
46
|
+
error=error_obj,
|
|
47
|
+
name="test-name",
|
|
48
|
+
status=status,
|
|
49
|
+
type="test-type",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def mock_base_client() -> Generator[Mock, None, None]:
|
|
55
|
+
yield Mock()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def mock_async_base_client() -> Generator[AsyncMock, None, None]:
|
|
60
|
+
yield AsyncMock()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_delete_calls_base_client(mock_base_client: Mock) -> None:
|
|
64
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
65
|
+
client.delete(id="123")
|
|
66
|
+
|
|
67
|
+
mock_base_client.request.assert_called_once()
|
|
68
|
+
call = mock_base_client.request.call_args[1]
|
|
69
|
+
assert call["method"] == "DELETE"
|
|
70
|
+
assert "/v1/audio-projects/123" in call["path"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_calls_base_client(mock_base_client: Mock) -> None:
|
|
74
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
75
|
+
mock_base_client.request.return_value = DummyResponse()
|
|
76
|
+
|
|
77
|
+
resp = client.get(id="abc")
|
|
78
|
+
|
|
79
|
+
mock_base_client.request.assert_called_once()
|
|
80
|
+
assert isinstance(resp, models.V1AudioProjectsGetResponse)
|
|
81
|
+
assert resp.id == "test-id"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_check_result_no_wait_no_download(mock_base_client: Mock) -> None:
|
|
85
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
86
|
+
mock_base_client.request.return_value = DummyResponse(status="queued")
|
|
87
|
+
|
|
88
|
+
resp = client.check_result(
|
|
89
|
+
id="xyz",
|
|
90
|
+
wait_for_completion=False,
|
|
91
|
+
download_outputs=False,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert resp.downloaded_paths is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_check_result_wait_until_complete(
|
|
98
|
+
monkeypatch: Any, mock_base_client: Mock
|
|
99
|
+
) -> None:
|
|
100
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
101
|
+
|
|
102
|
+
# First calls return queued, then complete
|
|
103
|
+
mock_base_client.request.side_effect = [
|
|
104
|
+
DummyResponse(status="queued"),
|
|
105
|
+
DummyResponse(status="queued"),
|
|
106
|
+
DummyResponse(status="complete"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr("time.sleep", lambda _: None) # type: ignore
|
|
110
|
+
|
|
111
|
+
resp = client.check_result(
|
|
112
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert resp.status == "complete"
|
|
116
|
+
assert resp.downloaded_paths is None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_check_result_download_outputs(
|
|
120
|
+
tmp_path: Path, mock_base_client: Mock, monkeypatch: Any
|
|
121
|
+
) -> None:
|
|
122
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
123
|
+
|
|
124
|
+
file_url = "https://example.com/file.mp3"
|
|
125
|
+
mock_base_client.request.return_value = DummyResponse(
|
|
126
|
+
status="complete",
|
|
127
|
+
download_url=file_url,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Create a mock response for httpx
|
|
131
|
+
mock_request = httpx.Request("GET", "https://example.com/file.mp3")
|
|
132
|
+
mock_response = httpx.Response(200, content=b"fake mp3", request=mock_request)
|
|
133
|
+
|
|
134
|
+
# Mock the httpx.Client class
|
|
135
|
+
class MockClient:
|
|
136
|
+
def __init__(self):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def __enter__(self) -> "MockClient":
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
def get(self, url: str) -> httpx.Response:
|
|
146
|
+
return mock_response
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr(httpx, "Client", MockClient)
|
|
149
|
+
|
|
150
|
+
resp = client.check_result(
|
|
151
|
+
id="xyz",
|
|
152
|
+
wait_for_completion=True,
|
|
153
|
+
download_outputs=True,
|
|
154
|
+
download_directory=str(tmp_path),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert resp.status == "complete"
|
|
158
|
+
assert resp.downloaded_paths
|
|
159
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
160
|
+
assert saved_file.exists()
|
|
161
|
+
assert saved_file.read_bytes() == b"fake mp3"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_check_result_error_status(mock_base_client: Mock) -> None:
|
|
165
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
166
|
+
mock_base_client.request.return_value = DummyResponse(status="error", error="Boom!")
|
|
167
|
+
|
|
168
|
+
resp = client.check_result(
|
|
169
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
170
|
+
)
|
|
171
|
+
assert resp.status == "error"
|
|
172
|
+
assert resp.error is not None
|
|
173
|
+
assert resp.error.message == "Boom!"
|
|
174
|
+
assert resp.downloaded_paths is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_check_result_canceled_status(mock_base_client: Mock) -> None:
|
|
178
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
179
|
+
mock_base_client.request.return_value = DummyResponse(status="canceled")
|
|
180
|
+
|
|
181
|
+
resp = client.check_result(
|
|
182
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
183
|
+
)
|
|
184
|
+
assert resp.status == "canceled"
|
|
185
|
+
assert resp.downloaded_paths is None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_check_result_poll_interval_default(
|
|
189
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
190
|
+
) -> None:
|
|
191
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
192
|
+
|
|
193
|
+
# First calls return queued, then complete
|
|
194
|
+
mock_base_client.request.side_effect = [
|
|
195
|
+
DummyResponse(status="queued"),
|
|
196
|
+
DummyResponse(status="complete"),
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
# Mock time.sleep to track calls
|
|
200
|
+
sleep_calls: List[float] = []
|
|
201
|
+
|
|
202
|
+
def mock_sleep(seconds: float) -> None:
|
|
203
|
+
sleep_calls.append(seconds)
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
206
|
+
|
|
207
|
+
resp = client.check_result(
|
|
208
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert resp.status == "complete"
|
|
212
|
+
# Should have slept once with default interval (0.5)
|
|
213
|
+
assert len(sleep_calls) == 1
|
|
214
|
+
assert sleep_calls[0] == 0.5
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_check_result_poll_interval_custom(
|
|
218
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
219
|
+
) -> None:
|
|
220
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
221
|
+
|
|
222
|
+
# Set custom poll interval
|
|
223
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "1.0")
|
|
224
|
+
|
|
225
|
+
# First calls return queued, then complete
|
|
226
|
+
mock_base_client.request.side_effect = [
|
|
227
|
+
DummyResponse(status="queued"),
|
|
228
|
+
DummyResponse(status="complete"),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# Mock time.sleep to track calls
|
|
232
|
+
sleep_calls: List[float] = []
|
|
233
|
+
|
|
234
|
+
def mock_sleep(seconds: float) -> None:
|
|
235
|
+
sleep_calls.append(seconds)
|
|
236
|
+
|
|
237
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
238
|
+
|
|
239
|
+
resp = client.check_result(
|
|
240
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
assert resp.status == "complete"
|
|
244
|
+
# Should have slept once with custom interval (1.0)
|
|
245
|
+
assert len(sleep_calls) == 1
|
|
246
|
+
assert sleep_calls[0] == 1.0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_check_result_poll_interval_multiple_polls(
|
|
250
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
251
|
+
) -> None:
|
|
252
|
+
client = AudioProjectsClient(base_client=mock_base_client)
|
|
253
|
+
|
|
254
|
+
# Set custom poll interval
|
|
255
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.1")
|
|
256
|
+
|
|
257
|
+
# Multiple calls return queued before complete
|
|
258
|
+
mock_base_client.request.side_effect = [
|
|
259
|
+
DummyResponse(status="queued"),
|
|
260
|
+
DummyResponse(status="queued"),
|
|
261
|
+
DummyResponse(status="queued"),
|
|
262
|
+
DummyResponse(status="complete"),
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
# Mock time.sleep to track calls
|
|
266
|
+
sleep_calls: List[float] = []
|
|
267
|
+
|
|
268
|
+
def mock_sleep(seconds: float) -> None:
|
|
269
|
+
sleep_calls.append(seconds)
|
|
270
|
+
|
|
271
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
272
|
+
|
|
273
|
+
resp = client.check_result(
|
|
274
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
assert resp.status == "complete"
|
|
278
|
+
# Should have slept 3 times with custom interval (0.1)
|
|
279
|
+
assert len(sleep_calls) == 3
|
|
280
|
+
assert all(sleep_time == 0.1 for sleep_time in sleep_calls)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_async_delete_calls_base_client(
|
|
285
|
+
mock_async_base_client: AsyncMock,
|
|
286
|
+
) -> None:
|
|
287
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
288
|
+
await client.delete(id="456")
|
|
289
|
+
|
|
290
|
+
mock_async_base_client.request.assert_called_once()
|
|
291
|
+
call = mock_async_base_client.request.call_args[1]
|
|
292
|
+
assert call["method"] == "DELETE"
|
|
293
|
+
assert "/v1/audio-projects/456" in call["path"]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.asyncio
|
|
297
|
+
async def test_async_get_calls_base_client(mock_async_base_client: AsyncMock) -> None:
|
|
298
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
299
|
+
mock_async_base_client.request.return_value = DummyResponse()
|
|
300
|
+
|
|
301
|
+
resp = await client.get(id="zzz")
|
|
302
|
+
|
|
303
|
+
mock_async_base_client.request.assert_called_once()
|
|
304
|
+
assert isinstance(resp, models.V1AudioProjectsGetResponse)
|
|
305
|
+
assert resp.id == "test-id"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_async_check_result_no_wait_no_download(
|
|
310
|
+
mock_async_base_client: AsyncMock,
|
|
311
|
+
) -> None:
|
|
312
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
313
|
+
mock_async_base_client.request.return_value = DummyResponse(status="queued")
|
|
314
|
+
|
|
315
|
+
resp = await client.check_result(
|
|
316
|
+
id="xyz",
|
|
317
|
+
wait_for_completion=False,
|
|
318
|
+
download_outputs=False,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
assert resp.downloaded_paths is None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.mark.asyncio
|
|
325
|
+
async def test_async_check_result_wait_until_complete(
|
|
326
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
327
|
+
) -> None:
|
|
328
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
329
|
+
|
|
330
|
+
# First calls return queued, then complete
|
|
331
|
+
mock_async_base_client.request.side_effect = [
|
|
332
|
+
DummyResponse(status="queued"),
|
|
333
|
+
DummyResponse(status="queued"),
|
|
334
|
+
DummyResponse(status="complete"),
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
monkeypatch.setattr("time.sleep", lambda _: None) # type: ignore
|
|
338
|
+
|
|
339
|
+
resp = await client.check_result(
|
|
340
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
assert resp.status == "complete"
|
|
344
|
+
assert resp.downloaded_paths is None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_async_check_result_download_outputs(
|
|
349
|
+
tmp_path: Path, mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
350
|
+
) -> None:
|
|
351
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
352
|
+
|
|
353
|
+
file_url = "https://example.com/file.mp3"
|
|
354
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
355
|
+
status="complete",
|
|
356
|
+
download_url=file_url,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Create a mock response for httpx
|
|
360
|
+
mock_request = httpx.Request("GET", "https://example.com/file.mp3")
|
|
361
|
+
mock_response = httpx.Response(200, content=b"fake mp3", request=mock_request)
|
|
362
|
+
|
|
363
|
+
# Mock the httpx.AsyncClient class
|
|
364
|
+
class MockAsyncClient:
|
|
365
|
+
def __init__(self):
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
async def __aenter__(self) -> "MockAsyncClient":
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
async def get(self, url: str) -> httpx.Response:
|
|
375
|
+
return mock_response
|
|
376
|
+
|
|
377
|
+
monkeypatch.setattr(httpx, "AsyncClient", MockAsyncClient)
|
|
378
|
+
|
|
379
|
+
resp = await client.check_result(
|
|
380
|
+
id="xyz",
|
|
381
|
+
wait_for_completion=True,
|
|
382
|
+
download_outputs=True,
|
|
383
|
+
download_directory=str(tmp_path),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
assert resp.status == "complete"
|
|
387
|
+
assert resp.downloaded_paths
|
|
388
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
389
|
+
assert saved_file.exists()
|
|
390
|
+
assert saved_file.read_bytes() == b"fake mp3"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@pytest.mark.asyncio
|
|
394
|
+
async def test_async_check_result_error_status(
|
|
395
|
+
mock_async_base_client: AsyncMock,
|
|
396
|
+
) -> None:
|
|
397
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
398
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
399
|
+
status="error", error="Boom!"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
resp = await client.check_result(
|
|
403
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
404
|
+
)
|
|
405
|
+
assert resp.status == "error"
|
|
406
|
+
assert resp.error is not None
|
|
407
|
+
assert resp.error.message == "Boom!"
|
|
408
|
+
assert resp.downloaded_paths is None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@pytest.mark.asyncio
|
|
412
|
+
async def test_async_check_result_canceled_status(
|
|
413
|
+
mock_async_base_client: AsyncMock,
|
|
414
|
+
) -> None:
|
|
415
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
416
|
+
mock_async_base_client.request.return_value = DummyResponse(status="canceled")
|
|
417
|
+
|
|
418
|
+
resp = await client.check_result(
|
|
419
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
420
|
+
)
|
|
421
|
+
assert resp.status == "canceled"
|
|
422
|
+
assert resp.downloaded_paths is None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@pytest.mark.asyncio
|
|
426
|
+
async def test_async_check_result_poll_interval_default(
|
|
427
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
428
|
+
) -> None:
|
|
429
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
430
|
+
|
|
431
|
+
# First calls return queued, then complete
|
|
432
|
+
mock_async_base_client.request.side_effect = [
|
|
433
|
+
DummyResponse(status="queued"),
|
|
434
|
+
DummyResponse(status="complete"),
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
# Mock time.sleep to track calls
|
|
438
|
+
sleep_calls: List[float] = []
|
|
439
|
+
|
|
440
|
+
def mock_sleep(seconds: float) -> None:
|
|
441
|
+
sleep_calls.append(seconds)
|
|
442
|
+
|
|
443
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
444
|
+
|
|
445
|
+
resp = await client.check_result(
|
|
446
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
assert resp.status == "complete"
|
|
450
|
+
# Should have slept once with default interval (0.5)
|
|
451
|
+
assert len(sleep_calls) == 1
|
|
452
|
+
assert sleep_calls[0] == 0.5
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@pytest.mark.asyncio
|
|
456
|
+
async def test_async_check_result_poll_interval_custom(
|
|
457
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
458
|
+
) -> None:
|
|
459
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
460
|
+
|
|
461
|
+
# Set custom poll interval
|
|
462
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "2.0")
|
|
463
|
+
|
|
464
|
+
# First calls return queued, then complete
|
|
465
|
+
mock_async_base_client.request.side_effect = [
|
|
466
|
+
DummyResponse(status="queued"),
|
|
467
|
+
DummyResponse(status="complete"),
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
# Mock time.sleep to track calls
|
|
471
|
+
sleep_calls: List[float] = []
|
|
472
|
+
|
|
473
|
+
def mock_sleep(seconds: float) -> None:
|
|
474
|
+
sleep_calls.append(seconds)
|
|
475
|
+
|
|
476
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
477
|
+
|
|
478
|
+
resp = await client.check_result(
|
|
479
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
assert resp.status == "complete"
|
|
483
|
+
# Should have slept once with custom interval (2.0)
|
|
484
|
+
assert len(sleep_calls) == 1
|
|
485
|
+
assert sleep_calls[0] == 2.0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@pytest.mark.asyncio
|
|
489
|
+
async def test_async_check_result_poll_interval_multiple_polls(
|
|
490
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
491
|
+
) -> None:
|
|
492
|
+
client = AsyncAudioProjectsClient(base_client=mock_async_base_client)
|
|
493
|
+
|
|
494
|
+
# Set custom poll interval
|
|
495
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.3")
|
|
496
|
+
|
|
497
|
+
# Multiple calls return queued before complete
|
|
498
|
+
mock_async_base_client.request.side_effect = [
|
|
499
|
+
DummyResponse(status="queued"),
|
|
500
|
+
DummyResponse(status="queued"),
|
|
501
|
+
DummyResponse(status="queued"),
|
|
502
|
+
DummyResponse(status="complete"),
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
# Mock time.sleep to track calls
|
|
506
|
+
sleep_calls: List[float] = []
|
|
507
|
+
|
|
508
|
+
def mock_sleep(seconds: float) -> None:
|
|
509
|
+
sleep_calls.append(seconds)
|
|
510
|
+
|
|
511
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
512
|
+
|
|
513
|
+
resp = await client.check_result(
|
|
514
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
assert resp.status == "complete"
|
|
518
|
+
# Should have slept 3 times with custom interval (0.3)
|
|
519
|
+
assert len(sleep_calls) == 3
|
|
520
|
+
assert all(sleep_time == 0.3 for sleep_time in sleep_calls)
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
## Module Functions
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
5
|
<!-- CUSTOM DOCS START -->
|
|
8
6
|
|
|
9
7
|
### Auto Subtitle Generator Generate Workflow <a name="generate"></a>
|
|
@@ -65,6 +63,7 @@ res = await client.v1.auto_subtitle_generator.generate(
|
|
|
65
63
|
```
|
|
66
64
|
|
|
67
65
|
<!-- CUSTOM DOCS END -->
|
|
66
|
+
|
|
68
67
|
### Auto Subtitle Generator <a name="create"></a>
|
|
69
68
|
|
|
70
69
|
Automatically generate subtitles for your video in multiple languages.
|
|
@@ -73,16 +72,16 @@ Automatically generate subtitles for your video in multiple languages.
|
|
|
73
72
|
|
|
74
73
|
#### Parameters
|
|
75
74
|
|
|
76
|
-
| Parameter
|
|
77
|
-
|
|
78
|
-
| `assets`
|
|
79
|
-
| `└─ video_file_path` |
|
|
80
|
-
| `end_seconds`
|
|
81
|
-
| `start_seconds`
|
|
82
|
-
| `style`
|
|
83
|
-
| `└─ custom_config`
|
|
84
|
-
| `└─ template`
|
|
85
|
-
| `name`
|
|
75
|
+
| Parameter | Required | Description | Example |
|
|
76
|
+
| -------------------- | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
77
|
+
| `assets` | ✓ | Provide the assets for auto subtitle generator | `{"video_file_path": "api-assets/id/1234.mp4"}` |
|
|
78
|
+
| `└─ video_file_path` | ✓ | This is the video used to add subtitles. This value is either - a direct URL to the video file - `file_path` field from the response of the [upload urls API](https://docs.magichour.ai/api-reference/files/generate-asset-upload-urls). Please refer to the [Input File documentation](https://docs.magichour.ai/api-reference/files/generate-asset-upload-urls#input-file) to learn more. | `"api-assets/id/1234.mp4"` |
|
|
79
|
+
| `end_seconds` | ✓ | The end time of the input video in seconds. This value is used to trim the input video. The value must be greater than 0.1, and more than the start_seconds. | `15.0` |
|
|
80
|
+
| `start_seconds` | ✓ | The start time of the input video in seconds. This value is used to trim the input video. The value must be greater than 0. | `0.0` |
|
|
81
|
+
| `style` | ✓ | Style of the subtitle. At least one of `.style.template` or `.style.custom_config` must be provided. * If only `.style.template` is provided, default values for the template will be used. * If both are provided, the fields in `.style.custom_config` will be used to overwrite the fields in `.style.template`. * If only `.style.custom_config` is provided, then all fields in `.style.custom_config` will be used. To use custom config only, the following `custom_config` params are required: * `.style.custom_config.font` * `.style.custom_config.text_color` * `.style.custom_config.vertical_position` * `.style.custom_config.horizontal_position` | `{}` |
|
|
82
|
+
| `└─ custom_config` | ✗ | Custom subtitle configuration. | `{"font": "Noto Sans", "font_size": 24.0, "font_style": "normal", "highlighted_text_color": "#FFD700", "horizontal_position": "center", "stroke_color": "#000000", "stroke_width": 1.0, "text_color": "#FFFFFF", "vertical_position": "bottom"}` |
|
|
83
|
+
| `└─ template` | ✗ | Preset subtitle templates. Please visit https://magichour.ai/create/auto-subtitle-generator to see the style of the existing templates. | `"cinematic"` |
|
|
84
|
+
| `name` | ✗ | The name of video. This value is mainly used for your own identification of the video. | `"Auto Subtitle video"` |
|
|
86
85
|
|
|
87
86
|
#### Synchronous Client
|
|
88
87
|
|
|
@@ -98,7 +97,6 @@ res = client.v1.auto_subtitle_generator.create(
|
|
|
98
97
|
style={},
|
|
99
98
|
name="Auto Subtitle video",
|
|
100
99
|
)
|
|
101
|
-
|
|
102
100
|
```
|
|
103
101
|
|
|
104
102
|
#### Asynchronous Client
|
|
@@ -115,15 +113,16 @@ res = await client.v1.auto_subtitle_generator.create(
|
|
|
115
113
|
style={},
|
|
116
114
|
name="Auto Subtitle video",
|
|
117
115
|
)
|
|
118
|
-
|
|
119
116
|
```
|
|
120
117
|
|
|
121
118
|
#### Response
|
|
122
119
|
|
|
123
120
|
##### Type
|
|
121
|
+
|
|
124
122
|
[V1AutoSubtitleGeneratorCreateResponse](/magic_hour/types/models/v1_auto_subtitle_generator_create_response.py)
|
|
125
123
|
|
|
126
124
|
##### Example
|
|
127
|
-
`{"credits_charged": 450, "estimated_frame_cost": 450, "id": "cuid-example"}`
|
|
128
|
-
|
|
129
125
|
|
|
126
|
+
```python
|
|
127
|
+
{"credits_charged": 450, "estimated_frame_cost": 450, "id": "cuid-example"}
|
|
128
|
+
```
|
|
@@ -42,6 +42,10 @@ from magic_hour.resources.v1.ai_talking_photo import (
|
|
|
42
42
|
AiTalkingPhotoClient,
|
|
43
43
|
AsyncAiTalkingPhotoClient,
|
|
44
44
|
)
|
|
45
|
+
from magic_hour.resources.v1.ai_voice_cloner import (
|
|
46
|
+
AiVoiceClonerClient,
|
|
47
|
+
AsyncAiVoiceClonerClient,
|
|
48
|
+
)
|
|
45
49
|
from magic_hour.resources.v1.ai_voice_generator import (
|
|
46
50
|
AiVoiceGeneratorClient,
|
|
47
51
|
AsyncAiVoiceGeneratorClient,
|
|
@@ -135,6 +139,7 @@ class V1Client:
|
|
|
135
139
|
self.video_to_video = VideoToVideoClient(base_client=self._base_client)
|
|
136
140
|
self.audio_projects = AudioProjectsClient(base_client=self._base_client)
|
|
137
141
|
self.ai_voice_generator = AiVoiceGeneratorClient(base_client=self._base_client)
|
|
142
|
+
self.ai_voice_cloner = AiVoiceClonerClient(base_client=self._base_client)
|
|
138
143
|
|
|
139
144
|
|
|
140
145
|
class AsyncV1Client:
|
|
@@ -185,3 +190,4 @@ class AsyncV1Client:
|
|
|
185
190
|
self.ai_voice_generator = AsyncAiVoiceGeneratorClient(
|
|
186
191
|
base_client=self._base_client
|
|
187
192
|
)
|
|
193
|
+
self.ai_voice_cloner = AsyncAiVoiceClonerClient(base_client=self._base_client)
|