magic_hour 0.35.0__py3-none-any.whl → 0.36.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/README.md +35 -0
- magic_hour/core/base_client.py +6 -5
- magic_hour/core/query.py +12 -6
- magic_hour/core/request.py +3 -3
- magic_hour/core/response.py +18 -14
- magic_hour/core/utils.py +3 -3
- magic_hour/environment.py +1 -1
- magic_hour/helpers/__init__.py +3 -0
- magic_hour/helpers/download.py +75 -0
- magic_hour/resources/v1/README.md +33 -0
- magic_hour/resources/v1/ai_clothes_changer/README.md +73 -0
- magic_hour/resources/v1/ai_clothes_changer/client.py +146 -0
- magic_hour/resources/v1/ai_face_editor/README.md +110 -0
- magic_hour/resources/v1/ai_face_editor/client.py +168 -0
- magic_hour/resources/v1/ai_gif_generator/README.md +59 -0
- magic_hour/resources/v1/ai_gif_generator/client.py +119 -0
- magic_hour/resources/v1/ai_headshot_generator/README.md +60 -0
- magic_hour/resources/v1/ai_headshot_generator/client.py +140 -0
- magic_hour/resources/v1/ai_image_editor/README.md +64 -0
- magic_hour/resources/v1/ai_image_editor/client.py +136 -0
- magic_hour/resources/v1/ai_image_generator/README.md +66 -0
- magic_hour/resources/v1/ai_image_generator/client.py +139 -0
- magic_hour/resources/v1/ai_image_upscaler/README.md +67 -0
- magic_hour/resources/v1/ai_image_upscaler/client.py +150 -0
- magic_hour/resources/v1/ai_meme_generator/README.md +71 -0
- magic_hour/resources/v1/ai_meme_generator/client.py +127 -0
- magic_hour/resources/v1/ai_photo_editor/README.md +98 -7
- magic_hour/resources/v1/ai_photo_editor/client.py +174 -0
- magic_hour/resources/v1/ai_qr_code_generator/README.md +63 -0
- magic_hour/resources/v1/ai_qr_code_generator/client.py +123 -0
- magic_hour/resources/v1/ai_talking_photo/README.md +74 -0
- magic_hour/resources/v1/ai_talking_photo/client.py +170 -0
- magic_hour/resources/v1/animation/README.md +100 -0
- magic_hour/resources/v1/animation/client.py +218 -0
- magic_hour/resources/v1/auto_subtitle_generator/README.md +69 -0
- magic_hour/resources/v1/auto_subtitle_generator/client.py +178 -0
- magic_hour/resources/v1/face_detection/README.md +59 -0
- magic_hour/resources/v1/face_detection/__init__.py +10 -2
- magic_hour/resources/v1/face_detection/client.py +179 -0
- magic_hour/resources/v1/face_swap/README.md +105 -8
- magic_hour/resources/v1/face_swap/client.py +242 -0
- magic_hour/resources/v1/face_swap_photo/README.md +84 -0
- magic_hour/resources/v1/face_swap_photo/client.py +172 -0
- magic_hour/resources/v1/files/README.md +6 -0
- magic_hour/resources/v1/files/client.py +350 -0
- magic_hour/resources/v1/files/client_test.py +414 -0
- magic_hour/resources/v1/files/upload_urls/README.md +8 -0
- magic_hour/resources/v1/image_background_remover/README.md +68 -0
- magic_hour/resources/v1/image_background_remover/client.py +130 -0
- magic_hour/resources/v1/image_projects/README.md +8 -0
- magic_hour/resources/v1/image_projects/__init__.py +10 -2
- magic_hour/resources/v1/image_projects/client.py +138 -0
- magic_hour/resources/v1/image_projects/client_test.py +527 -0
- magic_hour/resources/v1/image_to_video/README.md +77 -9
- magic_hour/resources/v1/image_to_video/client.py +186 -0
- magic_hour/resources/v1/lip_sync/README.md +87 -9
- magic_hour/resources/v1/lip_sync/client.py +210 -0
- magic_hour/resources/v1/photo_colorizer/README.md +59 -0
- magic_hour/resources/v1/photo_colorizer/client.py +130 -0
- magic_hour/resources/v1/text_to_video/README.md +68 -0
- magic_hour/resources/v1/text_to_video/client.py +151 -0
- magic_hour/resources/v1/video_projects/README.md +8 -0
- magic_hour/resources/v1/video_projects/__init__.py +10 -2
- magic_hour/resources/v1/video_projects/client.py +137 -0
- magic_hour/resources/v1/video_projects/client_test.py +527 -0
- magic_hour/resources/v1/video_to_video/README.md +98 -10
- magic_hour/resources/v1/video_to_video/client.py +222 -0
- magic_hour/types/params/__init__.py +58 -0
- magic_hour/types/params/v1_ai_clothes_changer_generate_body_assets.py +33 -0
- magic_hour/types/params/v1_ai_face_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_headshot_generator_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_image_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_image_upscaler_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_photo_editor_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_ai_talking_photo_generate_body_assets.py +26 -0
- magic_hour/types/params/v1_animation_generate_body_assets.py +39 -0
- magic_hour/types/params/v1_auto_subtitle_generator_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_face_detection_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_face_swap_create_body.py +12 -0
- magic_hour/types/params/v1_face_swap_create_body_style.py +33 -0
- magic_hour/types/params/v1_face_swap_generate_body_assets.py +56 -0
- magic_hour/types/params/v1_face_swap_generate_body_assets_face_mappings_item.py +25 -0
- magic_hour/types/params/v1_face_swap_photo_generate_body_assets.py +47 -0
- magic_hour/types/params/v1_face_swap_photo_generate_body_assets_face_mappings_item.py +25 -0
- magic_hour/types/params/v1_image_background_remover_generate_body_assets.py +27 -0
- magic_hour/types/params/v1_image_to_video_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_lip_sync_generate_body_assets.py +36 -0
- magic_hour/types/params/v1_photo_colorizer_generate_body_assets.py +17 -0
- magic_hour/types/params/v1_video_to_video_generate_body_assets.py +27 -0
- magic_hour-0.36.0.dist-info/METADATA +303 -0
- {magic_hour-0.35.0.dist-info → magic_hour-0.36.0.dist-info}/RECORD +93 -65
- magic_hour-0.35.0.dist-info/METADATA +0 -166
- {magic_hour-0.35.0.dist-info → magic_hour-0.36.0.dist-info}/LICENSE +0 -0
- {magic_hour-0.35.0.dist-info → magic_hour-0.36.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
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.video_projects.client import (
|
|
10
|
+
VideoProjectsClient,
|
|
11
|
+
AsyncVideoProjectsClient,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DummyResponse(models.V1VideoProjectsGetResponse):
|
|
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.V1VideoProjectsGetResponseError(
|
|
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
|
+
download=None,
|
|
39
|
+
downloads=[
|
|
40
|
+
models.V1VideoProjectsGetResponseDownloadsItem(
|
|
41
|
+
url=download_url, expires_at="2024-01-01T00:00:00Z"
|
|
42
|
+
)
|
|
43
|
+
]
|
|
44
|
+
if download_url
|
|
45
|
+
else [],
|
|
46
|
+
enabled=True,
|
|
47
|
+
end_seconds=10.0,
|
|
48
|
+
error=error_obj,
|
|
49
|
+
fps=30.0,
|
|
50
|
+
height=1080,
|
|
51
|
+
name="test-name",
|
|
52
|
+
start_seconds=0.0,
|
|
53
|
+
status=status,
|
|
54
|
+
total_frame_cost=0,
|
|
55
|
+
type="test-type",
|
|
56
|
+
width=1920,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def mock_base_client() -> Generator[Mock, None, None]:
|
|
62
|
+
yield Mock()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def mock_async_base_client() -> Generator[AsyncMock, None, None]:
|
|
67
|
+
yield AsyncMock()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_delete_calls_base_client(mock_base_client: Mock) -> None:
|
|
71
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
72
|
+
client.delete(id="123")
|
|
73
|
+
|
|
74
|
+
mock_base_client.request.assert_called_once()
|
|
75
|
+
call = mock_base_client.request.call_args[1]
|
|
76
|
+
assert call["method"] == "DELETE"
|
|
77
|
+
assert "/v1/video-projects/123" in call["path"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_get_calls_base_client(mock_base_client: Mock) -> None:
|
|
81
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
82
|
+
mock_base_client.request.return_value = DummyResponse()
|
|
83
|
+
|
|
84
|
+
resp = client.get(id="abc")
|
|
85
|
+
|
|
86
|
+
mock_base_client.request.assert_called_once()
|
|
87
|
+
assert isinstance(resp, models.V1VideoProjectsGetResponse)
|
|
88
|
+
assert resp.id == "test-id"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_check_result_no_wait_no_download(mock_base_client: Mock) -> None:
|
|
92
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
93
|
+
mock_base_client.request.return_value = DummyResponse(status="queued")
|
|
94
|
+
|
|
95
|
+
resp = client.check_result(
|
|
96
|
+
id="xyz",
|
|
97
|
+
wait_for_completion=False,
|
|
98
|
+
download_outputs=False,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
assert resp.downloaded_paths is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_check_result_wait_until_complete(
|
|
105
|
+
monkeypatch: Any, mock_base_client: Mock
|
|
106
|
+
) -> None:
|
|
107
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
108
|
+
|
|
109
|
+
# First calls return queued, then complete
|
|
110
|
+
mock_base_client.request.side_effect = [
|
|
111
|
+
DummyResponse(status="queued"),
|
|
112
|
+
DummyResponse(status="queued"),
|
|
113
|
+
DummyResponse(status="complete"),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr("time.sleep", lambda _: None) # type: ignore
|
|
117
|
+
|
|
118
|
+
resp = client.check_result(
|
|
119
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
assert resp.status == "complete"
|
|
123
|
+
assert resp.downloaded_paths is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_check_result_download_outputs(
|
|
127
|
+
tmp_path: Path, mock_base_client: Mock, monkeypatch: Any
|
|
128
|
+
) -> None:
|
|
129
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
130
|
+
|
|
131
|
+
file_url = "https://example.com/file.mp4"
|
|
132
|
+
mock_base_client.request.return_value = DummyResponse(
|
|
133
|
+
status="complete",
|
|
134
|
+
download_url=file_url,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Create a mock response for httpx
|
|
138
|
+
mock_request = httpx.Request("GET", "https://example.com/file.mp4")
|
|
139
|
+
mock_response = httpx.Response(200, content=b"fake mp4", request=mock_request)
|
|
140
|
+
|
|
141
|
+
# Mock the httpx.Client class
|
|
142
|
+
class MockClient:
|
|
143
|
+
def __init__(self):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def __enter__(self) -> "MockClient":
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def get(self, url: str) -> httpx.Response:
|
|
153
|
+
return mock_response
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(httpx, "Client", MockClient)
|
|
156
|
+
|
|
157
|
+
resp = client.check_result(
|
|
158
|
+
id="xyz",
|
|
159
|
+
wait_for_completion=True,
|
|
160
|
+
download_outputs=True,
|
|
161
|
+
download_directory=str(tmp_path),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert resp.status == "complete"
|
|
165
|
+
assert resp.downloaded_paths
|
|
166
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
167
|
+
assert saved_file.exists()
|
|
168
|
+
assert saved_file.read_bytes() == b"fake mp4"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_check_result_error_status(mock_base_client: Mock) -> None:
|
|
172
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
173
|
+
mock_base_client.request.return_value = DummyResponse(status="error", error="Boom!")
|
|
174
|
+
|
|
175
|
+
resp = client.check_result(
|
|
176
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
177
|
+
)
|
|
178
|
+
assert resp.status == "error"
|
|
179
|
+
assert resp.error is not None
|
|
180
|
+
assert resp.error.message == "Boom!"
|
|
181
|
+
assert resp.downloaded_paths is None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_check_result_canceled_status(mock_base_client: Mock) -> None:
|
|
185
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
186
|
+
mock_base_client.request.return_value = DummyResponse(status="canceled")
|
|
187
|
+
|
|
188
|
+
resp = client.check_result(
|
|
189
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
190
|
+
)
|
|
191
|
+
assert resp.status == "canceled"
|
|
192
|
+
assert resp.downloaded_paths is None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_check_result_poll_interval_default(
|
|
196
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
197
|
+
) -> None:
|
|
198
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
199
|
+
|
|
200
|
+
# First calls return queued, then complete
|
|
201
|
+
mock_base_client.request.side_effect = [
|
|
202
|
+
DummyResponse(status="queued"),
|
|
203
|
+
DummyResponse(status="complete"),
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
# Mock time.sleep to track calls
|
|
207
|
+
sleep_calls: List[float] = []
|
|
208
|
+
|
|
209
|
+
def mock_sleep(seconds: float) -> None:
|
|
210
|
+
sleep_calls.append(seconds)
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
213
|
+
|
|
214
|
+
resp = client.check_result(
|
|
215
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert resp.status == "complete"
|
|
219
|
+
# Should have slept once with default interval (0.5)
|
|
220
|
+
assert len(sleep_calls) == 1
|
|
221
|
+
assert sleep_calls[0] == 0.5
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_check_result_poll_interval_custom(
|
|
225
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
226
|
+
) -> None:
|
|
227
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
228
|
+
|
|
229
|
+
# Set custom poll interval
|
|
230
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "1.0")
|
|
231
|
+
|
|
232
|
+
# First calls return queued, then complete
|
|
233
|
+
mock_base_client.request.side_effect = [
|
|
234
|
+
DummyResponse(status="queued"),
|
|
235
|
+
DummyResponse(status="complete"),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# Mock time.sleep to track calls
|
|
239
|
+
sleep_calls: List[float] = []
|
|
240
|
+
|
|
241
|
+
def mock_sleep(seconds: float) -> None:
|
|
242
|
+
sleep_calls.append(seconds)
|
|
243
|
+
|
|
244
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
245
|
+
|
|
246
|
+
resp = client.check_result(
|
|
247
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
assert resp.status == "complete"
|
|
251
|
+
# Should have slept once with custom interval (1.0)
|
|
252
|
+
assert len(sleep_calls) == 1
|
|
253
|
+
assert sleep_calls[0] == 1.0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_check_result_poll_interval_multiple_polls(
|
|
257
|
+
mock_base_client: Mock, monkeypatch: Any
|
|
258
|
+
) -> None:
|
|
259
|
+
client = VideoProjectsClient(base_client=mock_base_client)
|
|
260
|
+
|
|
261
|
+
# Set custom poll interval
|
|
262
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.1")
|
|
263
|
+
|
|
264
|
+
# Multiple calls return queued before complete
|
|
265
|
+
mock_base_client.request.side_effect = [
|
|
266
|
+
DummyResponse(status="queued"),
|
|
267
|
+
DummyResponse(status="queued"),
|
|
268
|
+
DummyResponse(status="queued"),
|
|
269
|
+
DummyResponse(status="complete"),
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
# Mock time.sleep to track calls
|
|
273
|
+
sleep_calls: List[float] = []
|
|
274
|
+
|
|
275
|
+
def mock_sleep(seconds: float) -> None:
|
|
276
|
+
sleep_calls.append(seconds)
|
|
277
|
+
|
|
278
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
279
|
+
|
|
280
|
+
resp = client.check_result(
|
|
281
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
assert resp.status == "complete"
|
|
285
|
+
# Should have slept 3 times with custom interval (0.1)
|
|
286
|
+
assert len(sleep_calls) == 3
|
|
287
|
+
assert all(sleep_time == 0.1 for sleep_time in sleep_calls)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.asyncio
|
|
291
|
+
async def test_async_delete_calls_base_client(
|
|
292
|
+
mock_async_base_client: AsyncMock,
|
|
293
|
+
) -> None:
|
|
294
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
295
|
+
await client.delete(id="456")
|
|
296
|
+
|
|
297
|
+
mock_async_base_client.request.assert_called_once()
|
|
298
|
+
call = mock_async_base_client.request.call_args[1]
|
|
299
|
+
assert call["method"] == "DELETE"
|
|
300
|
+
assert "/v1/video-projects/456" in call["path"]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio
|
|
304
|
+
async def test_async_get_calls_base_client(mock_async_base_client: AsyncMock) -> None:
|
|
305
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
306
|
+
mock_async_base_client.request.return_value = DummyResponse()
|
|
307
|
+
|
|
308
|
+
resp = await client.get(id="zzz")
|
|
309
|
+
|
|
310
|
+
mock_async_base_client.request.assert_called_once()
|
|
311
|
+
assert isinstance(resp, models.V1VideoProjectsGetResponse)
|
|
312
|
+
assert resp.id == "test-id"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@pytest.mark.asyncio
|
|
316
|
+
async def test_async_check_result_no_wait_no_download(
|
|
317
|
+
mock_async_base_client: AsyncMock,
|
|
318
|
+
) -> None:
|
|
319
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
320
|
+
mock_async_base_client.request.return_value = DummyResponse(status="queued")
|
|
321
|
+
|
|
322
|
+
resp = await client.check_result(
|
|
323
|
+
id="xyz",
|
|
324
|
+
wait_for_completion=False,
|
|
325
|
+
download_outputs=False,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
assert resp.downloaded_paths is None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@pytest.mark.asyncio
|
|
332
|
+
async def test_async_check_result_wait_until_complete(
|
|
333
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
334
|
+
) -> None:
|
|
335
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
336
|
+
|
|
337
|
+
# First calls return queued, then complete
|
|
338
|
+
mock_async_base_client.request.side_effect = [
|
|
339
|
+
DummyResponse(status="queued"),
|
|
340
|
+
DummyResponse(status="queued"),
|
|
341
|
+
DummyResponse(status="complete"),
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
monkeypatch.setattr("time.sleep", lambda _: None) # type: ignore
|
|
345
|
+
|
|
346
|
+
resp = await client.check_result(
|
|
347
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
assert resp.status == "complete"
|
|
351
|
+
assert resp.downloaded_paths is None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
async def test_async_check_result_download_outputs(
|
|
356
|
+
tmp_path: Path, mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
357
|
+
) -> None:
|
|
358
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
359
|
+
|
|
360
|
+
file_url = "https://example.com/file.mp4"
|
|
361
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
362
|
+
status="complete",
|
|
363
|
+
download_url=file_url,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Create a mock response for httpx
|
|
367
|
+
mock_request = httpx.Request("GET", "https://example.com/file.mp4")
|
|
368
|
+
mock_response = httpx.Response(200, content=b"fake mp4", request=mock_request)
|
|
369
|
+
|
|
370
|
+
# Mock the httpx.AsyncClient class
|
|
371
|
+
class MockAsyncClient:
|
|
372
|
+
def __init__(self):
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
async def __aenter__(self) -> "MockAsyncClient":
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
async def get(self, url: str) -> httpx.Response:
|
|
382
|
+
return mock_response
|
|
383
|
+
|
|
384
|
+
monkeypatch.setattr(httpx, "AsyncClient", MockAsyncClient)
|
|
385
|
+
|
|
386
|
+
resp = await client.check_result(
|
|
387
|
+
id="xyz",
|
|
388
|
+
wait_for_completion=True,
|
|
389
|
+
download_outputs=True,
|
|
390
|
+
download_directory=str(tmp_path),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
assert resp.status == "complete"
|
|
394
|
+
assert resp.downloaded_paths
|
|
395
|
+
saved_file = Path(resp.downloaded_paths[0])
|
|
396
|
+
assert saved_file.exists()
|
|
397
|
+
assert saved_file.read_bytes() == b"fake mp4"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@pytest.mark.asyncio
|
|
401
|
+
async def test_async_check_result_error_status(
|
|
402
|
+
mock_async_base_client: AsyncMock,
|
|
403
|
+
) -> None:
|
|
404
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
405
|
+
mock_async_base_client.request.return_value = DummyResponse(
|
|
406
|
+
status="error", error="Boom!"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
resp = await client.check_result(
|
|
410
|
+
id="err", wait_for_completion=True, download_outputs=False
|
|
411
|
+
)
|
|
412
|
+
assert resp.status == "error"
|
|
413
|
+
assert resp.error is not None
|
|
414
|
+
assert resp.error.message == "Boom!"
|
|
415
|
+
assert resp.downloaded_paths is None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@pytest.mark.asyncio
|
|
419
|
+
async def test_async_check_result_canceled_status(
|
|
420
|
+
mock_async_base_client: AsyncMock,
|
|
421
|
+
) -> None:
|
|
422
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
423
|
+
mock_async_base_client.request.return_value = DummyResponse(status="canceled")
|
|
424
|
+
|
|
425
|
+
resp = await client.check_result(
|
|
426
|
+
id="cancel", wait_for_completion=True, download_outputs=False
|
|
427
|
+
)
|
|
428
|
+
assert resp.status == "canceled"
|
|
429
|
+
assert resp.downloaded_paths is None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@pytest.mark.asyncio
|
|
433
|
+
async def test_async_check_result_poll_interval_default(
|
|
434
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
435
|
+
) -> None:
|
|
436
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
437
|
+
|
|
438
|
+
# First calls return queued, then complete
|
|
439
|
+
mock_async_base_client.request.side_effect = [
|
|
440
|
+
DummyResponse(status="queued"),
|
|
441
|
+
DummyResponse(status="complete"),
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
# Mock time.sleep to track calls
|
|
445
|
+
sleep_calls: List[float] = []
|
|
446
|
+
|
|
447
|
+
def mock_sleep(seconds: float) -> None:
|
|
448
|
+
sleep_calls.append(seconds)
|
|
449
|
+
|
|
450
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
451
|
+
|
|
452
|
+
resp = await client.check_result(
|
|
453
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
assert resp.status == "complete"
|
|
457
|
+
# Should have slept once with default interval (0.5)
|
|
458
|
+
assert len(sleep_calls) == 1
|
|
459
|
+
assert sleep_calls[0] == 0.5
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@pytest.mark.asyncio
|
|
463
|
+
async def test_async_check_result_poll_interval_custom(
|
|
464
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
465
|
+
) -> None:
|
|
466
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
467
|
+
|
|
468
|
+
# Set custom poll interval
|
|
469
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "2.0")
|
|
470
|
+
|
|
471
|
+
# First calls return queued, then complete
|
|
472
|
+
mock_async_base_client.request.side_effect = [
|
|
473
|
+
DummyResponse(status="queued"),
|
|
474
|
+
DummyResponse(status="complete"),
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
# Mock time.sleep to track calls
|
|
478
|
+
sleep_calls: List[float] = []
|
|
479
|
+
|
|
480
|
+
def mock_sleep(seconds: float) -> None:
|
|
481
|
+
sleep_calls.append(seconds)
|
|
482
|
+
|
|
483
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
484
|
+
|
|
485
|
+
resp = await client.check_result(
|
|
486
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
assert resp.status == "complete"
|
|
490
|
+
# Should have slept once with custom interval (2.0)
|
|
491
|
+
assert len(sleep_calls) == 1
|
|
492
|
+
assert sleep_calls[0] == 2.0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.asyncio
|
|
496
|
+
async def test_async_check_result_poll_interval_multiple_polls(
|
|
497
|
+
mock_async_base_client: AsyncMock, monkeypatch: Any
|
|
498
|
+
) -> None:
|
|
499
|
+
client = AsyncVideoProjectsClient(base_client=mock_async_base_client)
|
|
500
|
+
|
|
501
|
+
# Set custom poll interval
|
|
502
|
+
monkeypatch.setenv("MAGIC_HOUR_POLL_INTERVAL", "0.3")
|
|
503
|
+
|
|
504
|
+
# Multiple calls return queued before complete
|
|
505
|
+
mock_async_base_client.request.side_effect = [
|
|
506
|
+
DummyResponse(status="queued"),
|
|
507
|
+
DummyResponse(status="queued"),
|
|
508
|
+
DummyResponse(status="queued"),
|
|
509
|
+
DummyResponse(status="complete"),
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
# Mock time.sleep to track calls
|
|
513
|
+
sleep_calls: List[float] = []
|
|
514
|
+
|
|
515
|
+
def mock_sleep(seconds: float) -> None:
|
|
516
|
+
sleep_calls.append(seconds)
|
|
517
|
+
|
|
518
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
519
|
+
|
|
520
|
+
resp = await client.check_result(
|
|
521
|
+
id="xyz", wait_for_completion=True, download_outputs=False
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
assert resp.status == "complete"
|
|
525
|
+
# Should have slept 3 times with custom interval (0.3)
|
|
526
|
+
assert len(sleep_calls) == 3
|
|
527
|
+
assert all(sleep_time == 0.3 for sleep_time in sleep_calls)
|
|
@@ -1,3 +1,82 @@
|
|
|
1
|
+
# v1_video_to_video
|
|
2
|
+
|
|
3
|
+
## Module Functions
|
|
4
|
+
|
|
5
|
+
<!-- CUSTOM DOCS START -->
|
|
6
|
+
|
|
7
|
+
### Video To Video 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.video_to_video.generate(
|
|
35
|
+
assets={"video_file_path": "/path/to/1234.mp4", "video_source": "file"},
|
|
36
|
+
end_seconds=15.0,
|
|
37
|
+
start_seconds=0.0,
|
|
38
|
+
style={
|
|
39
|
+
"art_style": "3D Render",
|
|
40
|
+
"model": "default",
|
|
41
|
+
"prompt": "string",
|
|
42
|
+
"prompt_type": "default",
|
|
43
|
+
"version": "default",
|
|
44
|
+
},
|
|
45
|
+
fps_resolution="HALF",
|
|
46
|
+
name="Video To Video video",
|
|
47
|
+
wait_for_completion=True,
|
|
48
|
+
download_outputs=True,
|
|
49
|
+
download_directory="outputs"
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### Asynchronous Client
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from magic_hour import AsyncClient
|
|
57
|
+
from os import getenv
|
|
58
|
+
|
|
59
|
+
client = AsyncClient(token=getenv("API_TOKEN"))
|
|
60
|
+
res = await client.v1.video_to_video.generate(
|
|
61
|
+
assets={"video_file_path": "/path/to/1234.mp4", "video_source": "file"},
|
|
62
|
+
end_seconds=15.0,
|
|
63
|
+
start_seconds=0.0,
|
|
64
|
+
style={
|
|
65
|
+
"art_style": "3D Render",
|
|
66
|
+
"model": "default",
|
|
67
|
+
"prompt": "string",
|
|
68
|
+
"prompt_type": "default",
|
|
69
|
+
"version": "default",
|
|
70
|
+
},
|
|
71
|
+
fps_resolution="HALF",
|
|
72
|
+
name="Video To Video video",
|
|
73
|
+
wait_for_completion=True,
|
|
74
|
+
download_outputs=True,
|
|
75
|
+
download_directory="outputs"
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
<!-- CUSTOM DOCS END -->
|
|
1
80
|
|
|
2
81
|
### Video-to-Video <a name="create"></a>
|
|
3
82
|
|
|
@@ -10,16 +89,24 @@ Get more information about this mode at our [product page](https://magichour.ai/
|
|
|
10
89
|
|
|
11
90
|
#### Parameters
|
|
12
91
|
|
|
13
|
-
| Parameter | Required | Description | Example |
|
|
14
|
-
|
|
15
|
-
| `assets` | ✓ | Provide the assets for video-to-video. For video, The `video_source` field determines whether `video_file_path` or `youtube_url` field is used | `{"video_file_path": "api-assets/id/1234.mp4", "video_source": "file"}` |
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
92
|
+
| Parameter | Required | Deprecated | Description | Example |
|
|
93
|
+
|-----------|:--------:|:----------:|-------------|--------|
|
|
94
|
+
| `assets` | ✓ | ✗ | Provide the assets for video-to-video. For video, The `video_source` field determines whether `video_file_path` or `youtube_url` field is used | `{"video_file_path": "api-assets/id/1234.mp4", "video_source": "file"}` |
|
|
95
|
+
| `└─ video_file_path` | ✗ | — | Required if `video_source` is `file`. 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"` |
|
|
96
|
+
| `└─ video_source` | ✓ | — | | `"file"` |
|
|
97
|
+
| `└─ youtube_url` | ✗ | — | Using a youtube video as the input source. This field is required if `video_source` is `youtube` | `"http://www.example.com"` |
|
|
98
|
+
| `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` |
|
|
99
|
+
| `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` |
|
|
100
|
+
| `style` | ✓ | ✗ | | `{"art_style": "3D Render", "model": "default", "prompt": "string", "prompt_type": "default", "version": "default"}` |
|
|
101
|
+
| `└─ art_style` | ✓ | — | | `"3D Render"` |
|
|
102
|
+
| `└─ model` | ✓ | — | * `Dreamshaper` - a good all-around model that works for both animations as well as realism. * `Absolute Reality` - better at realism, but you'll often get similar results with Dreamshaper as well. * `Flat 2D Anime` - best for a flat illustration style that's common in most anime. * `default` - use the default recommended model for the selected art style. | `"default"` |
|
|
103
|
+
| `└─ prompt` | ✓ | — | The prompt used for the video. Prompt is required if `prompt_type` is `custom` or `append_default`. If `prompt_type` is `default`, then the `prompt` value passed will be ignored. | `"string"` |
|
|
104
|
+
| `└─ prompt_type` | ✓ | — | * `default` - Use the default recommended prompt for the art style. * `custom` - Only use the prompt passed in the API. Note: for v1, lora prompt will still be auto added to apply the art style properly. * `append_default` - Add the default recommended prompt to the end of the prompt passed in the API. | `"default"` |
|
|
105
|
+
| `└─ version` | ✓ | — | * `v1` - more detail, closer prompt adherence, and frame-by-frame previews. * `v2` - faster, more consistent, and less noisy. * `default` - use the default version for the selected art style. | `"default"` |
|
|
106
|
+
| `fps_resolution` | ✗ | ✗ | Determines whether the resulting video will have the same frame per second as the original video, or half. * `FULL` - the result video will have the same FPS as the input video * `HALF` - the result video will have half the FPS as the input video | `"HALF"` |
|
|
107
|
+
| `height` | ✗ | ✓ | `height` is deprecated and no longer influences the output video's resolution. Output resolution is determined by the **minimum** of: - The resolution of the input video - The maximum resolution allowed by your subscription tier. See our [pricing page](https://magichour.ai/pricing) for more details. This field is retained only for backward compatibility and will be removed in a future release. | `123` |
|
|
108
|
+
| `name` | ✗ | ✗ | The name of video. This value is mainly used for your own identification of the video. | `"Video To Video video"` |
|
|
109
|
+
| `width` | ✗ | ✓ | `width` is deprecated and no longer influences the output video's resolution. Output resolution is determined by the **minimum** of: - The resolution of the input video - The maximum resolution allowed by your subscription tier. See our [pricing page](https://magichour.ai/pricing) for more details. This field is retained only for backward compatibility and will be removed in a future release. | `123` |
|
|
23
110
|
|
|
24
111
|
#### Synchronous Client
|
|
25
112
|
|
|
@@ -76,3 +163,4 @@ res = await client.v1.video_to_video.create(
|
|
|
76
163
|
|
|
77
164
|
##### Example
|
|
78
165
|
`{"credits_charged": 450, "estimated_frame_cost": 450, "id": "cuid-example"}`
|
|
166
|
+
|