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.
Files changed (73) hide show
  1. magic_hour/README.md +2 -3
  2. magic_hour/environment.py +1 -1
  3. magic_hour/helpers/download.py +2 -0
  4. magic_hour/resources/v1/README.md +2 -3
  5. magic_hour/resources/v1/ai_clothes_changer/README.md +13 -14
  6. magic_hour/resources/v1/ai_face_editor/README.md +26 -27
  7. magic_hour/resources/v1/ai_gif_generator/README.md +12 -13
  8. magic_hour/resources/v1/ai_gif_generator/client.py +2 -2
  9. magic_hour/resources/v1/ai_headshot_generator/README.md +13 -14
  10. magic_hour/resources/v1/ai_headshot_generator/client.py +2 -2
  11. magic_hour/resources/v1/ai_image_editor/README.md +24 -17
  12. magic_hour/resources/v1/ai_image_editor/client.py +40 -10
  13. magic_hour/resources/v1/ai_image_generator/README.md +26 -18
  14. magic_hour/resources/v1/ai_image_generator/client.py +14 -6
  15. magic_hour/resources/v1/ai_image_upscaler/README.md +14 -15
  16. magic_hour/resources/v1/ai_meme_generator/README.md +12 -13
  17. magic_hour/resources/v1/ai_photo_editor/README.md +22 -23
  18. magic_hour/resources/v1/ai_qr_code_generator/README.md +13 -14
  19. magic_hour/resources/v1/ai_qr_code_generator/client.py +4 -4
  20. magic_hour/resources/v1/ai_talking_photo/README.md +16 -17
  21. magic_hour/resources/v1/ai_voice_cloner/README.md +62 -0
  22. magic_hour/resources/v1/ai_voice_cloner/__init__.py +4 -0
  23. magic_hour/resources/v1/ai_voice_cloner/client.py +272 -0
  24. magic_hour/resources/v1/ai_voice_generator/README.md +66 -10
  25. magic_hour/resources/v1/ai_voice_generator/client.py +122 -0
  26. magic_hour/resources/v1/animation/README.md +24 -25
  27. magic_hour/resources/v1/audio_projects/README.md +58 -13
  28. magic_hour/resources/v1/audio_projects/__init__.py +10 -2
  29. magic_hour/resources/v1/audio_projects/client.py +137 -0
  30. magic_hour/resources/v1/audio_projects/client_test.py +520 -0
  31. magic_hour/resources/v1/auto_subtitle_generator/README.md +15 -16
  32. magic_hour/resources/v1/client.py +6 -0
  33. magic_hour/resources/v1/face_detection/README.md +21 -20
  34. magic_hour/resources/v1/face_swap/README.md +23 -25
  35. magic_hour/resources/v1/face_swap/client.py +2 -2
  36. magic_hour/resources/v1/face_swap_photo/README.md +13 -14
  37. magic_hour/resources/v1/files/README.md +1 -5
  38. magic_hour/resources/v1/files/upload_urls/README.md +11 -10
  39. magic_hour/resources/v1/files/upload_urls/client.py +6 -4
  40. magic_hour/resources/v1/image_background_remover/README.md +11 -12
  41. magic_hour/resources/v1/image_projects/README.md +12 -16
  42. magic_hour/resources/v1/image_to_video/README.md +19 -21
  43. magic_hour/resources/v1/lip_sync/README.md +27 -21
  44. magic_hour/resources/v1/lip_sync/client.py +15 -0
  45. magic_hour/resources/v1/photo_colorizer/README.md +10 -11
  46. magic_hour/resources/v1/text_to_video/README.md +15 -17
  47. magic_hour/resources/v1/video_projects/README.md +12 -16
  48. magic_hour/resources/v1/video_to_video/README.md +24 -26
  49. magic_hour/types/models/__init__.py +2 -0
  50. magic_hour/types/models/v1_ai_voice_cloner_create_response.py +27 -0
  51. magic_hour/types/models/v1_audio_projects_get_response.py +1 -1
  52. magic_hour/types/models/v1_video_projects_get_response.py +1 -1
  53. magic_hour/types/params/__init__.py +26 -0
  54. magic_hour/types/params/v1_ai_image_editor_create_body_assets.py +18 -4
  55. magic_hour/types/params/v1_ai_image_editor_create_body_style.py +13 -0
  56. magic_hour/types/params/v1_ai_image_editor_generate_body_assets.py +12 -1
  57. magic_hour/types/params/v1_ai_image_generator_create_body_style.py +16 -0
  58. magic_hour/types/params/v1_ai_talking_photo_create_body_style.py +6 -4
  59. magic_hour/types/params/v1_ai_voice_cloner_create_body.py +49 -0
  60. magic_hour/types/params/v1_ai_voice_cloner_create_body_assets.py +33 -0
  61. magic_hour/types/params/v1_ai_voice_cloner_create_body_style.py +28 -0
  62. magic_hour/types/params/v1_ai_voice_cloner_generate_body_assets.py +28 -0
  63. magic_hour/types/params/v1_ai_voice_generator_create_body_style.py +382 -2
  64. magic_hour/types/params/v1_face_swap_create_body_style.py +1 -1
  65. magic_hour/types/params/v1_files_upload_urls_create_body_items_item.py +1 -1
  66. magic_hour/types/params/v1_lip_sync_create_body.py +12 -0
  67. magic_hour/types/params/v1_lip_sync_create_body_style.py +37 -0
  68. magic_hour/types/params/v1_video_to_video_create_body.py +1 -1
  69. magic_hour/types/params/v1_video_to_video_create_body_style.py +32 -4
  70. {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/METADATA +77 -62
  71. {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/RECORD +73 -63
  72. {magic_hour-0.40.0.dist-info → magic_hour-0.44.0.dist-info}/LICENSE +0 -0
  73. {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 | Required | Description | Example |
77
- |-----------|:--------:|-------------|--------|
78
- | `assets` | | Provide the assets for auto subtitle generator | `{"video_file_path": "api-assets/id/1234.mp4"}` |
79
- | `└─ 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"` |
80
- | `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` |
81
- | `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` |
82
- | `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` | `{}` |
83
- | `└─ 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"}` |
84
- | `└─ template` | | Preset subtitle templates. Please visit https://magichour.ai/create/auto-subtitle-generator to see the style of the existing templates. | `"cinematic"` |
85
- | `name` | | The name of video. This value is mainly used for your own identification of the video. | `"Auto Subtitle video"` |
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)