magic_hour 0.41.0__py3-none-any.whl → 0.42.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.
Potentially problematic release.
This version of magic_hour might be problematic. Click here for more details.
- magic_hour/helpers/download.py +2 -0
- magic_hour/resources/v1/ai_voice_generator/README.md +56 -0
- magic_hour/resources/v1/ai_voice_generator/client.py +122 -0
- magic_hour/resources/v1/audio_projects/README.md +47 -0
- 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/lip_sync/README.md +12 -0
- magic_hour/resources/v1/lip_sync/client.py +5 -0
- {magic_hour-0.41.0.dist-info → magic_hour-0.42.0.dist-info}/METADATA +3 -1
- {magic_hour-0.41.0.dist-info → magic_hour-0.42.0.dist-info}/RECORD +13 -12
- {magic_hour-0.41.0.dist-info → magic_hour-0.42.0.dist-info}/LICENSE +0 -0
- {magic_hour-0.41.0.dist-info → magic_hour-0.42.0.dist-info}/WHEEL +0 -0
magic_hour/helpers/download.py
CHANGED
|
@@ -23,6 +23,7 @@ def download_files_sync(
|
|
|
23
23
|
downloads: Union[
|
|
24
24
|
List[models.V1ImageProjectsGetResponseDownloadsItem],
|
|
25
25
|
List[models.V1VideoProjectsGetResponseDownloadsItem],
|
|
26
|
+
List[models.V1AudioProjectsGetResponseDownloadsItem],
|
|
26
27
|
],
|
|
27
28
|
download_directory: Union[str, None] = None,
|
|
28
29
|
) -> List[str]:
|
|
@@ -51,6 +52,7 @@ async def download_files_async(
|
|
|
51
52
|
downloads: Union[
|
|
52
53
|
List[models.V1ImageProjectsGetResponseDownloadsItem],
|
|
53
54
|
List[models.V1VideoProjectsGetResponseDownloadsItem],
|
|
55
|
+
List[models.V1AudioProjectsGetResponseDownloadsItem],
|
|
54
56
|
],
|
|
55
57
|
download_directory: Union[str, None] = None,
|
|
56
58
|
) -> List[str]:
|
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
## Module Functions
|
|
4
4
|
|
|
5
|
+
<!-- CUSTOM DOCS START -->
|
|
6
|
+
|
|
7
|
+
### Ai Talking Photo Generate Workflow <a name="generate"></a>
|
|
8
|
+
|
|
9
|
+
The workflow performs the following action
|
|
10
|
+
|
|
11
|
+
1. upload local assets to Magic Hour storage. So you can pass in a local path instead of having to upload files yourself
|
|
12
|
+
2. trigger a generation
|
|
13
|
+
3. poll for a completion status. This is configurable
|
|
14
|
+
4. if success, download the output to local directory
|
|
15
|
+
|
|
16
|
+
> [!TIP]
|
|
17
|
+
> This is the recommended way to use the SDK unless you have specific needs where it is necessary to split up the actions.
|
|
18
|
+
|
|
19
|
+
#### Parameters
|
|
20
|
+
|
|
21
|
+
In Additional to the parameters listed in the `.create` section below, `.generate` introduces 3 new parameters:
|
|
22
|
+
|
|
23
|
+
- `wait_for_completion` (bool, default True): Whether to wait for the project to complete.
|
|
24
|
+
- `download_outputs` (bool, default True): Whether to download the generated files
|
|
25
|
+
- `download_directory` (str, optional): Directory to save downloaded files (defaults to current directory)
|
|
26
|
+
|
|
27
|
+
#### Synchronous Client
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from magic_hour import Client
|
|
31
|
+
from os import getenv
|
|
32
|
+
|
|
33
|
+
client = Client(token=getenv("API_TOKEN"))
|
|
34
|
+
res = client.v1.ai_talking_photo.generate(
|
|
35
|
+
style={"prompt": "Hello, how are you?", "voice_name": "Elon Musk"},
|
|
36
|
+
name="Voice Generator audio",
|
|
37
|
+
wait_for_completion=True,
|
|
38
|
+
download_outputs=True,
|
|
39
|
+
download_directory="outputs"
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
#### Asynchronous Client
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from magic_hour import AsyncClient
|
|
47
|
+
from os import getenv
|
|
48
|
+
|
|
49
|
+
client = AsyncClient(token=getenv("API_TOKEN"))
|
|
50
|
+
res = await client.v1.ai_talking_photo.generate(
|
|
51
|
+
style={"prompt": "Hello, how are you?", "voice_name": "Elon Musk"},
|
|
52
|
+
name="Voice Generator audio",
|
|
53
|
+
wait_for_completion=True,
|
|
54
|
+
download_outputs=True,
|
|
55
|
+
download_directory="outputs"
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
<!-- CUSTOM DOCS END -->
|
|
60
|
+
|
|
5
61
|
### AI Voice Generator <a name="create"></a>
|
|
6
62
|
|
|
7
63
|
Generate speech from text. Each character costs 0.05 credits. The cost is rounded up to the nearest whole number.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
|
|
3
|
+
from magic_hour.helpers.logger import get_sdk_logger
|
|
4
|
+
from magic_hour.resources.v1.audio_projects.client import (
|
|
5
|
+
AsyncAudioProjectsClient,
|
|
6
|
+
AudioProjectsClient,
|
|
7
|
+
)
|
|
3
8
|
from magic_hour.types import models, params
|
|
4
9
|
from make_api_request import (
|
|
5
10
|
AsyncBaseClient,
|
|
@@ -11,10 +16,70 @@ from make_api_request import (
|
|
|
11
16
|
)
|
|
12
17
|
|
|
13
18
|
|
|
19
|
+
logger = get_sdk_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
14
22
|
class AiVoiceGeneratorClient:
|
|
15
23
|
def __init__(self, *, base_client: SyncBaseClient):
|
|
16
24
|
self._base_client = base_client
|
|
17
25
|
|
|
26
|
+
def generate(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
style: params.V1AiVoiceGeneratorCreateBodyStyle,
|
|
30
|
+
name: typing.Union[
|
|
31
|
+
typing.Optional[str], type_utils.NotGiven
|
|
32
|
+
] = type_utils.NOT_GIVEN,
|
|
33
|
+
wait_for_completion: bool = True,
|
|
34
|
+
download_outputs: bool = True,
|
|
35
|
+
download_directory: typing.Optional[str] = None,
|
|
36
|
+
request_options: typing.Optional[RequestOptions] = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Generate AI voice (alias for create with additional functionality).
|
|
40
|
+
|
|
41
|
+
Generate speech from text. Each character costs 0.05 credits. The cost is rounded up to the nearest whole number.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
style: The content used to generate speech.
|
|
45
|
+
name: The name of audio. This value is mainly used for your own identification of the audio.
|
|
46
|
+
wait_for_completion: Whether to wait for the audio project to complete
|
|
47
|
+
download_outputs: Whether to download the outputs
|
|
48
|
+
download_directory: The directory to download the outputs to. If not provided, the outputs will be downloaded to the current working directory
|
|
49
|
+
request_options: Additional options to customize the HTTP request
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
V1AudioProjectsGetResponseWithDownloads: The response from the AI Voice Generator API with the downloaded paths if `download_outputs` is True.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
```py
|
|
56
|
+
response = client.v1.ai_voice_generator.generate(
|
|
57
|
+
style={"prompt": "Hello, how are you?", "voice_name": "Elon Musk"},
|
|
58
|
+
name="Generated Voice",
|
|
59
|
+
wait_for_completion=True,
|
|
60
|
+
download_outputs=True,
|
|
61
|
+
download_directory="outputs/",
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
create_response = self.create(
|
|
67
|
+
style=style,
|
|
68
|
+
name=name,
|
|
69
|
+
request_options=request_options,
|
|
70
|
+
)
|
|
71
|
+
logger.info(f"AI Voice Generator response: {create_response}")
|
|
72
|
+
|
|
73
|
+
audio_projects_client = AudioProjectsClient(base_client=self._base_client)
|
|
74
|
+
response = audio_projects_client.check_result(
|
|
75
|
+
id=create_response.id,
|
|
76
|
+
wait_for_completion=wait_for_completion,
|
|
77
|
+
download_outputs=download_outputs,
|
|
78
|
+
download_directory=download_directory,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return response
|
|
82
|
+
|
|
18
83
|
def create(
|
|
19
84
|
self,
|
|
20
85
|
*,
|
|
@@ -69,6 +134,63 @@ class AsyncAiVoiceGeneratorClient:
|
|
|
69
134
|
def __init__(self, *, base_client: AsyncBaseClient):
|
|
70
135
|
self._base_client = base_client
|
|
71
136
|
|
|
137
|
+
async def generate(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
style: params.V1AiVoiceGeneratorCreateBodyStyle,
|
|
141
|
+
name: typing.Union[
|
|
142
|
+
typing.Optional[str], type_utils.NotGiven
|
|
143
|
+
] = type_utils.NOT_GIVEN,
|
|
144
|
+
wait_for_completion: bool = True,
|
|
145
|
+
download_outputs: bool = True,
|
|
146
|
+
download_directory: typing.Optional[str] = None,
|
|
147
|
+
request_options: typing.Optional[RequestOptions] = None,
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
Generate AI voice (alias for create with additional functionality).
|
|
151
|
+
|
|
152
|
+
Generate speech from text. Each character costs 0.05 credits. The cost is rounded up to the nearest whole number.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
style: The content used to generate speech.
|
|
156
|
+
name: The name of audio. This value is mainly used for your own identification of the audio.
|
|
157
|
+
wait_for_completion: Whether to wait for the audio project to complete
|
|
158
|
+
download_outputs: Whether to download the outputs
|
|
159
|
+
download_directory: The directory to download the outputs to. If not provided, the outputs will be downloaded to the current working directory
|
|
160
|
+
request_options: Additional options to customize the HTTP request
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
V1AudioProjectsGetResponseWithDownloads: The response from the AI Voice Generator API with the downloaded paths if `download_outputs` is True.
|
|
164
|
+
|
|
165
|
+
Examples:
|
|
166
|
+
```py
|
|
167
|
+
response = await client.v1.ai_voice_generator.generate(
|
|
168
|
+
style={"prompt": "Hello, how are you?", "voice_name": "Elon Musk"},
|
|
169
|
+
name="Generated Voice",
|
|
170
|
+
wait_for_completion=True,
|
|
171
|
+
download_outputs=True,
|
|
172
|
+
download_directory="outputs/",
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
create_response = await self.create(
|
|
178
|
+
style=style,
|
|
179
|
+
name=name,
|
|
180
|
+
request_options=request_options,
|
|
181
|
+
)
|
|
182
|
+
logger.info(f"AI Voice Generator response: {create_response}")
|
|
183
|
+
|
|
184
|
+
audio_projects_client = AsyncAudioProjectsClient(base_client=self._base_client)
|
|
185
|
+
response = await audio_projects_client.check_result(
|
|
186
|
+
id=create_response.id,
|
|
187
|
+
wait_for_completion=wait_for_completion,
|
|
188
|
+
download_outputs=download_outputs,
|
|
189
|
+
download_directory=download_directory,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return response
|
|
193
|
+
|
|
72
194
|
async def create(
|
|
73
195
|
self,
|
|
74
196
|
*,
|
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
## Module Functions
|
|
4
4
|
|
|
5
|
+
<!-- CUSTOM DOCS START -->
|
|
6
|
+
|
|
7
|
+
### Check results <a name="check-result"></a>
|
|
8
|
+
|
|
9
|
+
Poll the details API to check on the status of the rendering. Optionally can also download the output
|
|
10
|
+
|
|
11
|
+
#### Parameters
|
|
12
|
+
|
|
13
|
+
| Parameter | Required | Description | Example |
|
|
14
|
+
| --------------------- | :------: | ---------------------------------------------------------------------------------------------------- | ---------------- |
|
|
15
|
+
| `id` | ✓ | Unique ID of the audio project. This value is returned by all of the POST APIs that create an audio. | `"cuid-example"` |
|
|
16
|
+
| `wait_for_completion` | ✗ | Whether to wait for the project to complete. | `True` |
|
|
17
|
+
| `download_outputs` | ✗ | Whether to download the generated files | `True` |
|
|
18
|
+
| `download_directory` | ✗ | Directory to save downloaded files (defaults to current directory) | `"./outputs"` |
|
|
19
|
+
|
|
20
|
+
#### Synchronous Client
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from magic_hour import Client
|
|
24
|
+
from os import getenv
|
|
25
|
+
|
|
26
|
+
client = Client(token=getenv("API_TOKEN"))
|
|
27
|
+
res = client.v1.audio_projects.check_result(
|
|
28
|
+
id="cuid-example",
|
|
29
|
+
wait_for_completion=True,
|
|
30
|
+
download_outputs=True,
|
|
31
|
+
download_directory="outputs",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
#### Asynchronous Client
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from magic_hour import AsyncClient
|
|
40
|
+
from os import getenv
|
|
41
|
+
|
|
42
|
+
client = AsyncClient(token=getenv("API_TOKEN"))
|
|
43
|
+
res = await client.v1.audio_projects.check_result(
|
|
44
|
+
id="cuid-example",
|
|
45
|
+
wait_for_completion=True,
|
|
46
|
+
download_outputs=True,
|
|
47
|
+
download_directory="outputs",
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
<!-- CUSTOM DOCS END -->
|
|
5
52
|
### Delete audio <a name="delete"></a>
|
|
6
53
|
|
|
7
54
|
Permanently delete the rendered audio file(s). This action is not reversible, please be sure before deleting.
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
from .client import
|
|
1
|
+
from .client import (
|
|
2
|
+
AsyncAudioProjectsClient,
|
|
3
|
+
AudioProjectsClient,
|
|
4
|
+
V1AudioProjectsGetResponseWithDownloads,
|
|
5
|
+
)
|
|
2
6
|
|
|
3
7
|
|
|
4
|
-
__all__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AsyncAudioProjectsClient",
|
|
10
|
+
"AudioProjectsClient",
|
|
11
|
+
"V1AudioProjectsGetResponseWithDownloads",
|
|
12
|
+
]
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pydantic
|
|
3
|
+
import time
|
|
1
4
|
import typing
|
|
2
5
|
|
|
6
|
+
from magic_hour.helpers.download import download_files_async, download_files_sync
|
|
7
|
+
from magic_hour.helpers.logger import get_sdk_logger
|
|
3
8
|
from magic_hour.types import models
|
|
4
9
|
from make_api_request import (
|
|
5
10
|
AsyncBaseClient,
|
|
@@ -9,10 +14,83 @@ from make_api_request import (
|
|
|
9
14
|
)
|
|
10
15
|
|
|
11
16
|
|
|
17
|
+
logger = get_sdk_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class V1AudioProjectsGetResponseWithDownloads(models.V1AudioProjectsGetResponse):
|
|
21
|
+
downloaded_paths: typing.Optional[typing.List[str]] = pydantic.Field(
|
|
22
|
+
default=None, alias="downloaded_paths"
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
The paths to the downloaded files.
|
|
26
|
+
|
|
27
|
+
This field is only populated if `download_outputs` is True and the audio project is complete.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
12
31
|
class AudioProjectsClient:
|
|
13
32
|
def __init__(self, *, base_client: SyncBaseClient):
|
|
14
33
|
self._base_client = base_client
|
|
15
34
|
|
|
35
|
+
def check_result(
|
|
36
|
+
self,
|
|
37
|
+
id: str,
|
|
38
|
+
wait_for_completion: bool,
|
|
39
|
+
download_outputs: bool,
|
|
40
|
+
download_directory: typing.Optional[str] = None,
|
|
41
|
+
) -> V1AudioProjectsGetResponseWithDownloads:
|
|
42
|
+
"""
|
|
43
|
+
Check the result of an audio project with optional waiting and downloading.
|
|
44
|
+
|
|
45
|
+
This method retrieves the status of an audio project and optionally waits for completion
|
|
46
|
+
and downloads the output files.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
id: Unique ID of the audio project
|
|
50
|
+
wait_for_completion: Whether to wait for the audio project to complete
|
|
51
|
+
download_outputs: Whether to download the outputs
|
|
52
|
+
download_directory: The directory to download the outputs to. If not provided,
|
|
53
|
+
the outputs will be downloaded to the current working directory
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
V1AudioProjectsGetResponseWithDownloads: The audio project response with optional
|
|
57
|
+
downloaded file paths included
|
|
58
|
+
"""
|
|
59
|
+
api_response = self.get(id=id)
|
|
60
|
+
if not wait_for_completion:
|
|
61
|
+
response = V1AudioProjectsGetResponseWithDownloads(
|
|
62
|
+
**api_response.model_dump()
|
|
63
|
+
)
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
poll_interval = float(os.getenv("MAGIC_HOUR_POLL_INTERVAL", "0.5"))
|
|
67
|
+
|
|
68
|
+
status = api_response.status
|
|
69
|
+
|
|
70
|
+
while status not in ["complete", "error", "canceled"]:
|
|
71
|
+
api_response = self.get(id=id)
|
|
72
|
+
status = api_response.status
|
|
73
|
+
time.sleep(poll_interval)
|
|
74
|
+
|
|
75
|
+
if api_response.status != "complete":
|
|
76
|
+
log = logger.error if api_response.status == "error" else logger.info
|
|
77
|
+
log(
|
|
78
|
+
f"Audio project {id} has status {api_response.status}: {api_response.error}"
|
|
79
|
+
)
|
|
80
|
+
return V1AudioProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
81
|
+
|
|
82
|
+
if not download_outputs:
|
|
83
|
+
return V1AudioProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
84
|
+
|
|
85
|
+
downloaded_paths = download_files_sync(
|
|
86
|
+
downloads=api_response.downloads,
|
|
87
|
+
download_directory=download_directory,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return V1AudioProjectsGetResponseWithDownloads(
|
|
91
|
+
**api_response.model_dump(), downloaded_paths=downloaded_paths
|
|
92
|
+
)
|
|
93
|
+
|
|
16
94
|
def delete(
|
|
17
95
|
self, *, id: str, request_options: typing.Optional[RequestOptions] = None
|
|
18
96
|
) -> None:
|
|
@@ -95,6 +173,65 @@ class AsyncAudioProjectsClient:
|
|
|
95
173
|
def __init__(self, *, base_client: AsyncBaseClient):
|
|
96
174
|
self._base_client = base_client
|
|
97
175
|
|
|
176
|
+
async def check_result(
|
|
177
|
+
self,
|
|
178
|
+
id: str,
|
|
179
|
+
wait_for_completion: bool,
|
|
180
|
+
download_outputs: bool,
|
|
181
|
+
download_directory: typing.Optional[str] = None,
|
|
182
|
+
) -> V1AudioProjectsGetResponseWithDownloads:
|
|
183
|
+
"""
|
|
184
|
+
Check the result of an audio project with optional waiting and downloading.
|
|
185
|
+
|
|
186
|
+
This method retrieves the status of an audio project and optionally waits for completion
|
|
187
|
+
and downloads the output files.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
id: Unique ID of the audio project
|
|
191
|
+
wait_for_completion: Whether to wait for the audio project to complete
|
|
192
|
+
download_outputs: Whether to download the outputs
|
|
193
|
+
download_directory: The directory to download the outputs to. If not provided,
|
|
194
|
+
the outputs will be downloaded to the current working directory
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
V1AudioProjectsGetResponseWithDownloads: The audio project response with optional
|
|
198
|
+
downloaded file paths included
|
|
199
|
+
"""
|
|
200
|
+
api_response = await self.get(id=id)
|
|
201
|
+
if not wait_for_completion:
|
|
202
|
+
response = V1AudioProjectsGetResponseWithDownloads(
|
|
203
|
+
**api_response.model_dump()
|
|
204
|
+
)
|
|
205
|
+
return response
|
|
206
|
+
|
|
207
|
+
poll_interval = float(os.getenv("MAGIC_HOUR_POLL_INTERVAL", "0.5"))
|
|
208
|
+
|
|
209
|
+
status = api_response.status
|
|
210
|
+
|
|
211
|
+
while status not in ["complete", "error", "canceled"]:
|
|
212
|
+
api_response = await self.get(id=id)
|
|
213
|
+
status = api_response.status
|
|
214
|
+
time.sleep(poll_interval)
|
|
215
|
+
|
|
216
|
+
if api_response.status != "complete":
|
|
217
|
+
log = logger.error if api_response.status == "error" else logger.info
|
|
218
|
+
log(
|
|
219
|
+
f"Audio project {id} has status {api_response.status}: {api_response.error}"
|
|
220
|
+
)
|
|
221
|
+
return V1AudioProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
222
|
+
|
|
223
|
+
if not download_outputs:
|
|
224
|
+
return V1AudioProjectsGetResponseWithDownloads(**api_response.model_dump())
|
|
225
|
+
|
|
226
|
+
downloaded_paths = await download_files_async(
|
|
227
|
+
downloads=api_response.downloads,
|
|
228
|
+
download_directory=download_directory,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return V1AudioProjectsGetResponseWithDownloads(
|
|
232
|
+
**api_response.model_dump(), downloaded_paths=downloaded_paths
|
|
233
|
+
)
|
|
234
|
+
|
|
98
235
|
async def delete(
|
|
99
236
|
self, *, id: str, request_options: typing.Optional[RequestOptions] = None
|
|
100
237
|
) -> None:
|
|
@@ -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)
|
|
@@ -40,6 +40,9 @@ res = client.v1.lip_sync.generate(
|
|
|
40
40
|
"video_file_path": "/path/to/1234.mp4",
|
|
41
41
|
"video_source": "file",
|
|
42
42
|
},
|
|
43
|
+
style={
|
|
44
|
+
"generation_mode": "lite",
|
|
45
|
+
},
|
|
43
46
|
end_seconds=15.0,
|
|
44
47
|
start_seconds=0.0,
|
|
45
48
|
max_fps_limit=12.0,
|
|
@@ -63,6 +66,9 @@ res = await client.v1.lip_sync.generate(
|
|
|
63
66
|
"video_file_path": "/path/to/1234.mp4",
|
|
64
67
|
"video_source": "file",
|
|
65
68
|
},
|
|
69
|
+
style={
|
|
70
|
+
"generation_mode": "lite",
|
|
71
|
+
},
|
|
66
72
|
end_seconds=15.0,
|
|
67
73
|
start_seconds=0.0,
|
|
68
74
|
max_fps_limit=12.0,
|
|
@@ -114,6 +120,9 @@ res = client.v1.lip_sync.create(
|
|
|
114
120
|
"video_file_path": "api-assets/id/1234.mp4",
|
|
115
121
|
"video_source": "file",
|
|
116
122
|
},
|
|
123
|
+
style={
|
|
124
|
+
"generation_mode": "lite",
|
|
125
|
+
},
|
|
117
126
|
end_seconds=15.0,
|
|
118
127
|
start_seconds=0.0,
|
|
119
128
|
max_fps_limit=12.0,
|
|
@@ -135,6 +144,9 @@ res = await client.v1.lip_sync.create(
|
|
|
135
144
|
"video_file_path": "api-assets/id/1234.mp4",
|
|
136
145
|
"video_source": "file",
|
|
137
146
|
},
|
|
147
|
+
style={
|
|
148
|
+
"generation_mode": "lite",
|
|
149
|
+
},
|
|
138
150
|
end_seconds=15.0,
|
|
139
151
|
start_seconds=0.0,
|
|
140
152
|
max_fps_limit=12.0,
|
|
@@ -39,6 +39,9 @@ class LipSyncClient:
|
|
|
39
39
|
name: typing.Union[
|
|
40
40
|
typing.Optional[str], type_utils.NotGiven
|
|
41
41
|
] = type_utils.NOT_GIVEN,
|
|
42
|
+
style: typing.Union[
|
|
43
|
+
typing.Optional[params.V1LipSyncCreateBodyStyle], type_utils.NotGiven
|
|
44
|
+
] = type_utils.NOT_GIVEN,
|
|
42
45
|
width: typing.Union[
|
|
43
46
|
typing.Optional[int], type_utils.NotGiven
|
|
44
47
|
] = type_utils.NOT_GIVEN,
|
|
@@ -56,6 +59,7 @@ class LipSyncClient:
|
|
|
56
59
|
height: `height` is deprecated and no longer influences the output video's resolution.
|
|
57
60
|
max_fps_limit: Defines the maximum FPS (frames per second) for the output video. If the input video's FPS is lower than this limit, the output video will retain the input FPS. This is useful for reducing unnecessary frame usage in scenarios where high FPS is not required.
|
|
58
61
|
name: The name of video. This value is mainly used for your own identification of the video.
|
|
62
|
+
style: Attributes used to dictate the style of the output
|
|
59
63
|
width: `width` is deprecated and no longer influences the output video's resolution.
|
|
60
64
|
assets: Provide the assets for lip-sync. For video, The `video_source` field determines whether `video_file_path` or `youtube_url` field is used
|
|
61
65
|
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.
|
|
@@ -108,6 +112,7 @@ class LipSyncClient:
|
|
|
108
112
|
height=height,
|
|
109
113
|
max_fps_limit=max_fps_limit,
|
|
110
114
|
name=name,
|
|
115
|
+
style=style,
|
|
111
116
|
width=width,
|
|
112
117
|
request_options=request_options,
|
|
113
118
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: magic_hour
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.42.0
|
|
4
4
|
Summary: Python SDK for Magic Hour API
|
|
5
5
|
Requires-Python: >=3.8,<4.0
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -226,6 +226,7 @@ download_urls = result.downloads
|
|
|
226
226
|
### [v1.ai_voice_generator](magic_hour/resources/v1/ai_voice_generator/README.md)
|
|
227
227
|
|
|
228
228
|
* [create](magic_hour/resources/v1/ai_voice_generator/README.md#create) - AI Voice Generator
|
|
229
|
+
* [generate](magic_hour/resources/v1/ai_voice_generator/README.md#generate) - Ai Voice Generator Generate Workflow
|
|
229
230
|
|
|
230
231
|
### [v1.animation](magic_hour/resources/v1/animation/README.md)
|
|
231
232
|
|
|
@@ -234,6 +235,7 @@ download_urls = result.downloads
|
|
|
234
235
|
|
|
235
236
|
### [v1.audio_projects](magic_hour/resources/v1/audio_projects/README.md)
|
|
236
237
|
|
|
238
|
+
* [check-result](magic_hour/resources/v1/audio_projects/README.md#check-result) - Check results
|
|
237
239
|
* [delete](magic_hour/resources/v1/audio_projects/README.md#delete) - Delete audio
|
|
238
240
|
* [get](magic_hour/resources/v1/audio_projects/README.md#get) - Get audio details
|
|
239
241
|
|
|
@@ -3,7 +3,7 @@ magic_hour/__init__.py,sha256=ypWA4msPy3T-U2PAyRSAXIQc0m2J9aY8wXnfmIupAEc,214
|
|
|
3
3
|
magic_hour/client.py,sha256=oHuz0FRKuedtwwPSkFLaSjoEWt_0N57hJ0rEDKbNcFw,1712
|
|
4
4
|
magic_hour/environment.py,sha256=tAHxEiK1_4L0_MZntbQuQA1Np-kDHZ8M6QbpON1uwsY,535
|
|
5
5
|
magic_hour/helpers/__init__.py,sha256=pO6sgb41iVe_IN2dApfl2l3BXOWIb4i5pHl_if-oo_E,176
|
|
6
|
-
magic_hour/helpers/download.py,sha256=
|
|
6
|
+
magic_hour/helpers/download.py,sha256=_aZmpr9tzqzeyPbvc1Xz95GJhEgOqH1-GHh66Ild57E,2336
|
|
7
7
|
magic_hour/helpers/logger.py,sha256=Z8ljney670Kc8DaSzlHdH9N8RXmj2_qWPovLUoC0tNY,210
|
|
8
8
|
magic_hour/resources/v1/README.md,sha256=P5Y3atMFloJotSgJWt5Wtz2z9YnV3B5bcWBu-vppzP8,1793
|
|
9
9
|
magic_hour/resources/v1/__init__.py,sha256=Aj0sjVcoijjQyieNBxv2_uewPYC2vO2UG-ehoBgCz5E,86
|
|
@@ -40,15 +40,16 @@ magic_hour/resources/v1/ai_qr_code_generator/client.py,sha256=rLw0V6cE2AncqM9Bz_
|
|
|
40
40
|
magic_hour/resources/v1/ai_talking_photo/README.md,sha256=M33SsFSeGhOVlDKy14qR7ClUD-6qrrMghnwOxgq7vBU,5609
|
|
41
41
|
magic_hour/resources/v1/ai_talking_photo/__init__.py,sha256=ZTDD_IRBoR7GSdGWCVEK2-LOEsKUdGEHZZvDHa9MOnA,134
|
|
42
42
|
magic_hour/resources/v1/ai_talking_photo/client.py,sha256=kbFM3peM5NEzUle1miup7Xe2q_HwL7u7kzuD1DkcEBc,12529
|
|
43
|
-
magic_hour/resources/v1/ai_voice_generator/README.md,sha256=
|
|
43
|
+
magic_hour/resources/v1/ai_voice_generator/README.md,sha256=gfGlxiXD04a8HutUjMb1GVoEs0D6kxnMNjSJny5y9Zo,3569
|
|
44
44
|
magic_hour/resources/v1/ai_voice_generator/__init__.py,sha256=Zl1HuxfafN3hzJCCCD_YyCX_y2v4uAihSDAC8GCcE8U,142
|
|
45
|
-
magic_hour/resources/v1/ai_voice_generator/client.py,sha256=
|
|
45
|
+
magic_hour/resources/v1/ai_voice_generator/client.py,sha256=VhrGUhdznSDSBgzLNa7pjqjWC7-cXzYVUyjyGf4c7xo,8749
|
|
46
46
|
magic_hour/resources/v1/animation/README.md,sha256=ZRyrlI2qh2aYJ-sHF7_8vFMLcQlmDcQEsu78LGVu958,7602
|
|
47
47
|
magic_hour/resources/v1/animation/__init__.py,sha256=M6KUe6TEZl_DAdyn1HFQ2kHYanZo6xy3mvUdCN264hQ,114
|
|
48
48
|
magic_hour/resources/v1/animation/client.py,sha256=vVoYF6P-OU83kAXwZyRJngv6C_zoArXXuM6nTPy-Jvw,15447
|
|
49
|
-
magic_hour/resources/v1/audio_projects/README.md,sha256=
|
|
50
|
-
magic_hour/resources/v1/audio_projects/__init__.py,sha256=
|
|
51
|
-
magic_hour/resources/v1/audio_projects/client.py,sha256=
|
|
49
|
+
magic_hour/resources/v1/audio_projects/README.md,sha256=pRoIuJvk5BcgdQq4WVQt98WQ0dk8FYFvkLKE-Jue1SM,4375
|
|
50
|
+
magic_hour/resources/v1/audio_projects/__init__.py,sha256=QG5bxr8z40p54-38pawFobEowm_nrx5IURMU4hjrCtI,246
|
|
51
|
+
magic_hour/resources/v1/audio_projects/client.py,sha256=9AIDLuAU6TswVWS7BAnmL2F-PbVz-UcCwy1OjLnOfas,10979
|
|
52
|
+
magic_hour/resources/v1/audio_projects/client_test.py,sha256=n1nMtZgc9TTBKGiiVcMciX3jXHtMs5cAPRrD6s9e6L8,15776
|
|
52
53
|
magic_hour/resources/v1/auto_subtitle_generator/README.md,sha256=IGi0wXf4T_iBI6fFXVr7enTDKYNCwN3HorDOjQuRyJA,5309
|
|
53
54
|
magic_hour/resources/v1/auto_subtitle_generator/__init__.py,sha256=dnWFEiSdIl3AwFVprqWHSMzqpeHgZz9wPEMxm7c3Xnc,162
|
|
54
55
|
magic_hour/resources/v1/auto_subtitle_generator/client.py,sha256=QZREuGcgIVc3hNaBvTfDuBRj0pvnr_uu0YzFkFhsd_s,14803
|
|
@@ -79,9 +80,9 @@ magic_hour/resources/v1/image_projects/client_test.py,sha256=5KH4s0vG13SEUkB6pAa
|
|
|
79
80
|
magic_hour/resources/v1/image_to_video/README.md,sha256=3_wM8ZtJfZrWMLQnq092v2uIycFvIIjm8tPz7T0A3OY,6273
|
|
80
81
|
magic_hour/resources/v1/image_to_video/__init__.py,sha256=tY_ABo6evwKQBRSq-M84lNX-pXqmxoozukmrO6NhCgA,126
|
|
81
82
|
magic_hour/resources/v1/image_to_video/client.py,sha256=fUajB5-kKzT3WC5u7EAeY8_N4k1_r36JmwML4GquQwc,17392
|
|
82
|
-
magic_hour/resources/v1/lip_sync/README.md,sha256=
|
|
83
|
+
magic_hour/resources/v1/lip_sync/README.md,sha256=4KSIj9aOz3vgwdMkpe-X6l86j9BvYPl-RhAS51u4bsU,7605
|
|
83
84
|
magic_hour/resources/v1/lip_sync/__init__.py,sha256=MlKUAoHNSKcuNzVyqNfLnLtD_PsqEn3l1TtVpPC1JqQ,106
|
|
84
|
-
magic_hour/resources/v1/lip_sync/client.py,sha256=
|
|
85
|
+
magic_hour/resources/v1/lip_sync/client.py,sha256=hFnNps9HSEG6Zi3x56PP5WorbFzm7NnicRiLfR2kZ8o,19769
|
|
85
86
|
magic_hour/resources/v1/photo_colorizer/README.md,sha256=wql3I3PNIwMZkiCWaQCbMujrtIHW1y4vJ5ce5HX7q0k,3430
|
|
86
87
|
magic_hour/resources/v1/photo_colorizer/__init__.py,sha256=7rDjkeUzWG5GXw_4RD1XH7Ygy-_0_OUFX99IgE_RVbE,134
|
|
87
88
|
magic_hour/resources/v1/photo_colorizer/client.py,sha256=mW5VYDmsvU9phtkiY008zarM1CCaWTMTeOTY4zaWDfI,8861
|
|
@@ -216,7 +217,7 @@ magic_hour/types/params/v1_video_to_video_create_body.py,sha256=BV52jJcXnOsKaw-M
|
|
|
216
217
|
magic_hour/types/params/v1_video_to_video_create_body_assets.py,sha256=XwdoqT1x5ElcWb6qUfmtabNsyaatEsOygB4gJ154y-M,1660
|
|
217
218
|
magic_hour/types/params/v1_video_to_video_create_body_style.py,sha256=cYiwKRRSJpwHwL_00wxXcXHvym8q1VsT2VBFOPfJNQM,6000
|
|
218
219
|
magic_hour/types/params/v1_video_to_video_generate_body_assets.py,sha256=j06Y1RKZKwEXW9Zdfhr3Lntx7eDAKpH-k1N1XkKuqPM,915
|
|
219
|
-
magic_hour-0.
|
|
220
|
-
magic_hour-0.
|
|
221
|
-
magic_hour-0.
|
|
222
|
-
magic_hour-0.
|
|
220
|
+
magic_hour-0.42.0.dist-info/LICENSE,sha256=F3fxj7JXPgB2K0uj8YXRsVss4u-Dgt_-U3V4VXsivNI,1070
|
|
221
|
+
magic_hour-0.42.0.dist-info/METADATA,sha256=Tr3fizT_MQzlJf0YRwTsIc69GnsZt1fRhUPmbp9Vaxo,13189
|
|
222
|
+
magic_hour-0.42.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
223
|
+
magic_hour-0.42.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|