free-short-video 5.1.2
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.
- package/.dockerignore +32 -0
- package/LICENSE +21 -0
- package/README.md +531 -0
- package/bin/cli.js +191 -0
- package/core/__init__.py +35 -0
- package/core/api/__init__.py +7 -0
- package/core/api/agnes_chat.py +294 -0
- package/core/api/agnes_image.py +256 -0
- package/core/api/agnes_models.py +84 -0
- package/core/api/agnes_video.py +535 -0
- package/core/api/error_collector.py +215 -0
- package/core/api/rate_limiter.py +142 -0
- package/core/artifacts.py +574 -0
- package/core/audio/__init__.py +9 -0
- package/core/audio/subtitle.py +1103 -0
- package/core/audio/tts.py +194 -0
- package/core/audio/voices.py +330 -0
- package/core/compositor/__init__.py +6 -0
- package/core/compositor/concatenator.py +653 -0
- package/core/compositor/processor.py +124 -0
- package/core/compositor/watermark.py +221 -0
- package/core/config.py +474 -0
- package/core/image_generator.py +8 -0
- package/core/path_security.py +58 -0
- package/core/pipeline.py +8 -0
- package/core/pipelines/__init__.py +466 -0
- package/core/pipelines/anchor_video.py +406 -0
- package/core/pipelines/creative_video.py +1968 -0
- package/core/pipelines/manuscript_video.py +540 -0
- package/core/pipelines/multi_scene.py +380 -0
- package/core/pipelines/poetry_video.py +504 -0
- package/core/pipelines/simple_video.py +143 -0
- package/core/screenwriter.py +1860 -0
- package/core/task_manager.py +211 -0
- package/core/video_generator.py +8 -0
- package/models/__init__.py +75 -0
- package/models/task.py +548 -0
- package/package.json +36 -0
- package/requirements.txt +13 -0
- package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
- package/resource/fonts/STHeitiMedium.ttc +0 -0
- package/server.py +2026 -0
- package/static/favicon.ico +0 -0
- package/static/icon.png +0 -0
- package/static/index.html +5277 -0
- package/utils/__init__.py +0 -0
- package/utils/image.py +36 -0
- package/utils/video.py +28 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""core.api.agnes_video — Agnes Video API 封装(从 core/video_generator.py 迁移)"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
15
|
+
from core.api.rate_limiter import get_rate_limiter
|
|
16
|
+
from utils.video import download_video
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://apihub.agnes-ai.com/v1"
|
|
21
|
+
API_ROOT = "https://apihub.agnes-ai.com"
|
|
22
|
+
|
|
23
|
+
DURATION_PRESETS = {
|
|
24
|
+
5: (121, 24),
|
|
25
|
+
10: (241, 24),
|
|
26
|
+
15: (361, 24),
|
|
27
|
+
18: (409, 24), # capped at 409 (API max for 720p); actual ~17s
|
|
28
|
+
20: (409, 24), # capped at 409 (API max for 720p); actual ~17s
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VideoOutput:
|
|
33
|
+
def __init__(self, fmt: str, ext: str, data: str):
|
|
34
|
+
self.fmt = fmt
|
|
35
|
+
self.ext = ext
|
|
36
|
+
self.data = data
|
|
37
|
+
|
|
38
|
+
def save(self, path: str) -> None:
|
|
39
|
+
if self.fmt == "url":
|
|
40
|
+
download_video(self.data, path)
|
|
41
|
+
else:
|
|
42
|
+
with open(path, "wb") as f:
|
|
43
|
+
f.write(self.data if isinstance(self.data, bytes) else self.data.encode())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgnesVideoAPI:
|
|
47
|
+
"""Agnes Video 生成 API 封装(t2v / i2v / ti2vid / keyframes)。"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
api_key: str,
|
|
52
|
+
model: str = "agnes-video-v2.0",
|
|
53
|
+
default_duration: int = 5,
|
|
54
|
+
max_retries: int = 5,
|
|
55
|
+
retry_base_delay: float = 30.0,
|
|
56
|
+
):
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.model = model
|
|
59
|
+
self.default_duration = default_duration
|
|
60
|
+
self.max_retries = max_retries
|
|
61
|
+
self.retry_base_delay = retry_base_delay
|
|
62
|
+
self.shutdown_event = None
|
|
63
|
+
self.headers = {
|
|
64
|
+
"Authorization": f"Bearer {api_key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def _path_to_b64(self, path: str) -> str:
|
|
69
|
+
with open(path, "rb") as f:
|
|
70
|
+
b64 = base64.b64encode(f.read()).decode("utf-8")
|
|
71
|
+
mime = mimetypes.guess_type(path)[0] or "image/png"
|
|
72
|
+
return f"data:{mime};base64,{b64}"
|
|
73
|
+
|
|
74
|
+
async def _resolve_image_ref(self, ref: str) -> str:
|
|
75
|
+
if ref.startswith(("http://", "https://")):
|
|
76
|
+
return ref
|
|
77
|
+
if ref.startswith("data:"):
|
|
78
|
+
return ref
|
|
79
|
+
if os.path.exists(ref):
|
|
80
|
+
url_file = ref + ".url"
|
|
81
|
+
# P12: 缓存过期检查(预签名 URL 有效期有限,超过 1 小时则重新上传)
|
|
82
|
+
_URL_CACHE_MAX_AGE = 3600 # 1 小时
|
|
83
|
+
if os.path.exists(url_file):
|
|
84
|
+
try:
|
|
85
|
+
with open(url_file, "r") as f:
|
|
86
|
+
cache_data = json.loads(f.read())
|
|
87
|
+
cached_url = cache_data.get("url", "")
|
|
88
|
+
cached_ts = cache_data.get("ts", 0)
|
|
89
|
+
age = time.time() - cached_ts
|
|
90
|
+
if cached_url and age < _URL_CACHE_MAX_AGE:
|
|
91
|
+
logger.info(
|
|
92
|
+
f"[AgnesVideo] Using cached hosted URL (age={age:.0f}s): "
|
|
93
|
+
f"{cached_url[:80]}..."
|
|
94
|
+
)
|
|
95
|
+
return cached_url
|
|
96
|
+
if cached_url:
|
|
97
|
+
logger.info(
|
|
98
|
+
f"[AgnesVideo] Cached URL expired (age={age:.0f}s), re-uploading"
|
|
99
|
+
)
|
|
100
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
101
|
+
logger.debug(f"[AgnesVideo] Failed to read cached URL: {e}")
|
|
102
|
+
# 兼容旧格式纯文本缓存文件
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.debug(f"[AgnesVideo] Failed to read legacy URL cache: {e}")
|
|
105
|
+
url = await self._upload_image_to_url(ref)
|
|
106
|
+
if url:
|
|
107
|
+
try:
|
|
108
|
+
cache_data = {"url": url, "ts": time.time()}
|
|
109
|
+
tmp_file = url_file + ".tmp"
|
|
110
|
+
with open(tmp_file, "w") as f:
|
|
111
|
+
json.dump(cache_data, f)
|
|
112
|
+
f.flush()
|
|
113
|
+
os.fsync(f.fileno())
|
|
114
|
+
os.replace(tmp_file, url_file)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.debug(f"[AgnesVideo] Failed to cache URL: {e}")
|
|
117
|
+
return url
|
|
118
|
+
logger.warning("[AgnesVideo] Image upload failed, falling back to base64.")
|
|
119
|
+
return self._path_to_b64(ref)
|
|
120
|
+
return ref
|
|
121
|
+
|
|
122
|
+
async def _upload_image_to_url(self, image_path: str, retries: int = 3) -> Optional[str]:
|
|
123
|
+
for attempt in range(retries):
|
|
124
|
+
if self.shutdown_event and self.shutdown_event.is_set():
|
|
125
|
+
logger.info("[AgnesVideo] Image upload cancelled by shutdown")
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
b64_data = self._path_to_b64(image_path)
|
|
129
|
+
payload = {
|
|
130
|
+
"model": "agnes-image-2.1-flash",
|
|
131
|
+
"prompt": "Keep the image exactly as it is",
|
|
132
|
+
"n": 1,
|
|
133
|
+
"size": "1024x1024",
|
|
134
|
+
"extra_body": {
|
|
135
|
+
"response_format": "url",
|
|
136
|
+
"image": b64_data,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
logger.info(f"[AgnesVideo] Uploading image to hosted URL (attempt {attempt + 1}/{retries})...")
|
|
140
|
+
await asyncio.to_thread(get_rate_limiter().acquire)
|
|
141
|
+
resp = await asyncio.to_thread(
|
|
142
|
+
requests.post,
|
|
143
|
+
f"{BASE_URL}/images/generations",
|
|
144
|
+
headers=self.headers,
|
|
145
|
+
json=payload,
|
|
146
|
+
timeout=(30, 120),
|
|
147
|
+
)
|
|
148
|
+
if resp.status_code == 429:
|
|
149
|
+
delay = 30 * (attempt + 1)
|
|
150
|
+
logger.warning(f"[AgnesVideo] Image upload 429, retry in {delay}s...")
|
|
151
|
+
await asyncio.sleep(delay)
|
|
152
|
+
continue
|
|
153
|
+
resp.raise_for_status()
|
|
154
|
+
result = resp.json()
|
|
155
|
+
data_list = result.get("data", [])
|
|
156
|
+
if data_list:
|
|
157
|
+
url = data_list[0].get("url", "")
|
|
158
|
+
if url:
|
|
159
|
+
logger.info(f"[AgnesVideo] Image uploaded to hosted URL: {url[:80]}...")
|
|
160
|
+
return url
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.warning(f"[AgnesVideo] Image upload attempt {attempt + 1}/{retries} failed: {e}")
|
|
163
|
+
if attempt < retries - 1:
|
|
164
|
+
await asyncio.sleep(15)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# API frame limits by resolution tier (from Agnes API error messages)
|
|
168
|
+
_FRAME_LIMITS = {
|
|
169
|
+
"1080p": 169,
|
|
170
|
+
"720p": 409,
|
|
171
|
+
"480p": 961,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _get_max_frames(width: int, height: int) -> int:
|
|
176
|
+
"""Get the maximum allowed num_frames for the given resolution."""
|
|
177
|
+
pixels = width * height
|
|
178
|
+
if pixels > 1280 * 720:
|
|
179
|
+
return 169 # 1080p tier
|
|
180
|
+
elif pixels > 854 * 480:
|
|
181
|
+
return 409 # 720p tier
|
|
182
|
+
else:
|
|
183
|
+
return 961 # 480p tier
|
|
184
|
+
|
|
185
|
+
def _get_frame_config(self, duration: Optional[int] = None,
|
|
186
|
+
width: int = 1152, height: int = 768) -> tuple:
|
|
187
|
+
d = duration or self.default_duration
|
|
188
|
+
max_nf = self._get_max_frames(width, height)
|
|
189
|
+
if d in DURATION_PRESETS:
|
|
190
|
+
nf, fr = DURATION_PRESETS[d]
|
|
191
|
+
if nf <= max_nf:
|
|
192
|
+
return nf, fr
|
|
193
|
+
# preset exceeds limit for this resolution, cap it
|
|
194
|
+
logger.warning(
|
|
195
|
+
f"[AgnesVideo] Duration preset {d}s has {nf} frames, "
|
|
196
|
+
f"exceeds {max_nf} for {width}x{height}. Capping."
|
|
197
|
+
)
|
|
198
|
+
return max_nf, fr
|
|
199
|
+
best = None
|
|
200
|
+
for nf in range(9, min(410, max_nf + 1), 8):
|
|
201
|
+
fr = round(nf / d)
|
|
202
|
+
if 1 <= fr <= 60:
|
|
203
|
+
best = (nf, fr)
|
|
204
|
+
return best or DURATION_PRESETS[5]
|
|
205
|
+
|
|
206
|
+
async def _poll_task(self, video_id: str, interval: int = 60,
|
|
207
|
+
max_poll_duration: int = 1800,
|
|
208
|
+
max_consecutive_failures: int = 10,
|
|
209
|
+
progress_callback=None) -> dict:
|
|
210
|
+
last_status = ""
|
|
211
|
+
poll_count = 0
|
|
212
|
+
consecutive_failures = 0
|
|
213
|
+
start_time = asyncio.get_event_loop().time()
|
|
214
|
+
curl_cmd = (
|
|
215
|
+
f'curl -s -H "Authorization: Bearer $AGNES_API_KEY" '
|
|
216
|
+
f'"{API_ROOT}/agnesapi?video_id={video_id}"'
|
|
217
|
+
)
|
|
218
|
+
while True:
|
|
219
|
+
# M2: 每次轮询前检查停止信号
|
|
220
|
+
if self.shutdown_event and self.shutdown_event.is_set():
|
|
221
|
+
raise RuntimeError("Video generation cancelled by user")
|
|
222
|
+
|
|
223
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
224
|
+
if elapsed > max_poll_duration:
|
|
225
|
+
error_msg = (
|
|
226
|
+
f"[AgnesVideo] Polling timed out after {max_poll_duration}s "
|
|
227
|
+
f"for video {video_id[:16]}"
|
|
228
|
+
)
|
|
229
|
+
collect_error(
|
|
230
|
+
"video", "poll_task",
|
|
231
|
+
prompt=curl_cmd,
|
|
232
|
+
error_type="PollingTimeout",
|
|
233
|
+
error_message=error_msg,
|
|
234
|
+
extra={"video_id": video_id[:16], "elapsed_s": int(elapsed)},
|
|
235
|
+
)
|
|
236
|
+
raise RuntimeError(error_msg)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if poll_count % 10 == 0:
|
|
240
|
+
logger.info(f"[AgnesVideo] Polling video {video_id[:16]}... (poll #{poll_count + 1}, elapsed {elapsed:.0f}s)")
|
|
241
|
+
# 全局限速:每次轮询都消耗一个令牌
|
|
242
|
+
await asyncio.to_thread(get_rate_limiter().acquire)
|
|
243
|
+
# M2: 用 wait_for 包裹以支持取消
|
|
244
|
+
resp = await asyncio.wait_for(
|
|
245
|
+
asyncio.to_thread(
|
|
246
|
+
requests.get,
|
|
247
|
+
f"{API_ROOT}/agnesapi?video_id={video_id}",
|
|
248
|
+
headers=self.headers,
|
|
249
|
+
timeout=15,
|
|
250
|
+
),
|
|
251
|
+
timeout=30,
|
|
252
|
+
)
|
|
253
|
+
resp.raise_for_status()
|
|
254
|
+
result = resp.json()
|
|
255
|
+
status = result.get("status", "")
|
|
256
|
+
progress = result.get("progress", 0)
|
|
257
|
+
poll_count += 1
|
|
258
|
+
consecutive_failures = 0 # reset on success
|
|
259
|
+
|
|
260
|
+
if status != last_status:
|
|
261
|
+
logger.info(f"[AgnesVideo] Video {video_id[:16]}... status={status} progress={progress}%")
|
|
262
|
+
last_status = status
|
|
263
|
+
|
|
264
|
+
if progress_callback:
|
|
265
|
+
progress_callback(status, progress, curl_cmd)
|
|
266
|
+
|
|
267
|
+
if status in ("completed", "COMPLETED"):
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
if status in ("failed", "FAILED"):
|
|
271
|
+
err = result.get("error") or "unknown error"
|
|
272
|
+
error_msg = f"Video generation failed: {err}"
|
|
273
|
+
collect_error(
|
|
274
|
+
"video", "poll_task",
|
|
275
|
+
prompt=curl_cmd,
|
|
276
|
+
error_type="VideoFailed",
|
|
277
|
+
error_message=error_msg,
|
|
278
|
+
response_body=resp.text,
|
|
279
|
+
extra={"video_id": video_id[:16], "status": status},
|
|
280
|
+
)
|
|
281
|
+
raise RuntimeError(error_msg)
|
|
282
|
+
except (requests.exceptions.RequestException, asyncio.TimeoutError) as e:
|
|
283
|
+
consecutive_failures += 1
|
|
284
|
+
logger.warning(
|
|
285
|
+
f"[AgnesVideo] Poll error ({consecutive_failures}/{max_consecutive_failures}): {e}"
|
|
286
|
+
)
|
|
287
|
+
# 每次轮询失败都记录
|
|
288
|
+
collect_error_from_exception(
|
|
289
|
+
"video", "poll_task",
|
|
290
|
+
exc=e, prompt=curl_cmd,
|
|
291
|
+
retry_count=consecutive_failures,
|
|
292
|
+
extra={"video_id": video_id[:16], "poll_count": poll_count},
|
|
293
|
+
)
|
|
294
|
+
if consecutive_failures >= max_consecutive_failures:
|
|
295
|
+
error_msg = (
|
|
296
|
+
f"[AgnesVideo] Polling failed after {max_consecutive_failures} "
|
|
297
|
+
f"consecutive errors for video {video_id[:16]}"
|
|
298
|
+
)
|
|
299
|
+
collect_error_from_exception(
|
|
300
|
+
"video", "poll_task",
|
|
301
|
+
exc=e, prompt=curl_cmd,
|
|
302
|
+
retry_count=max_consecutive_failures,
|
|
303
|
+
extra={"video_id": video_id[:16], "poll_count": poll_count},
|
|
304
|
+
)
|
|
305
|
+
raise RuntimeError(error_msg)
|
|
306
|
+
|
|
307
|
+
await asyncio.sleep(interval)
|
|
308
|
+
|
|
309
|
+
async def _submit_with_retry(self, payload: dict, mode_desc: str) -> str:
|
|
310
|
+
frame_reductions_left = 2 # allow up to 2 frame-count reductions on 400
|
|
311
|
+
for attempt in range(self.max_retries):
|
|
312
|
+
if self.shutdown_event and self.shutdown_event.is_set():
|
|
313
|
+
raise RuntimeError("Video generation cancelled by user")
|
|
314
|
+
try:
|
|
315
|
+
logger.info(f"[AgnesVideo] Submitting {mode_desc} (attempt {attempt + 1}/{self.max_retries})...")
|
|
316
|
+
# 全局限速:在发起提交请求前获取令牌
|
|
317
|
+
await asyncio.to_thread(get_rate_limiter().acquire)
|
|
318
|
+
# M2: 缩短读超时从 180s 到 60s,使 stop() 更快生效
|
|
319
|
+
resp = await asyncio.wait_for(
|
|
320
|
+
asyncio.to_thread(
|
|
321
|
+
requests.post,
|
|
322
|
+
f"{BASE_URL}/videos",
|
|
323
|
+
headers=self.headers,
|
|
324
|
+
json=payload,
|
|
325
|
+
timeout=(15, 60),
|
|
326
|
+
),
|
|
327
|
+
timeout=90,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if resp.status_code == 200:
|
|
331
|
+
result = resp.json()
|
|
332
|
+
video_id = result.get("video_id") or result.get("task_id") or result.get("id")
|
|
333
|
+
if video_id:
|
|
334
|
+
return video_id
|
|
335
|
+
|
|
336
|
+
if resp.status_code == 429:
|
|
337
|
+
delay = self.retry_base_delay * (attempt + 1)
|
|
338
|
+
logger.warning(
|
|
339
|
+
f"[AgnesVideo] 429 rate limit on {mode_desc}, "
|
|
340
|
+
f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
|
|
341
|
+
)
|
|
342
|
+
collect_error(
|
|
343
|
+
"video", "submit_video",
|
|
344
|
+
prompt=payload.get("prompt", ""),
|
|
345
|
+
error_type="RateLimit429",
|
|
346
|
+
error_message="HTTP 429: rate limited",
|
|
347
|
+
status_code=429,
|
|
348
|
+
response_body=resp.text,
|
|
349
|
+
retry_count=attempt + 1,
|
|
350
|
+
extra={"mode": mode_desc},
|
|
351
|
+
)
|
|
352
|
+
await asyncio.sleep(delay)
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if resp.status_code >= 500:
|
|
356
|
+
delay = self.retry_base_delay * (attempt + 1)
|
|
357
|
+
logger.warning(
|
|
358
|
+
f"[AgnesVideo] {resp.status_code} server error on {mode_desc}, "
|
|
359
|
+
f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
|
|
360
|
+
)
|
|
361
|
+
collect_error(
|
|
362
|
+
"video", "submit_video",
|
|
363
|
+
prompt=payload.get("prompt", ""),
|
|
364
|
+
error_type=f"HTTP{resp.status_code}",
|
|
365
|
+
error_message=f"HTTP {resp.status_code}: server error",
|
|
366
|
+
status_code=resp.status_code,
|
|
367
|
+
response_body=resp.text,
|
|
368
|
+
retry_count=attempt + 1,
|
|
369
|
+
extra={"mode": mode_desc},
|
|
370
|
+
)
|
|
371
|
+
await asyncio.sleep(delay)
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# HTTP 400 with num_frames exceeded → reduce frames and retry
|
|
375
|
+
error_text = resp.text[:500]
|
|
376
|
+
if (resp.status_code == 400
|
|
377
|
+
and "num_frames" in error_text
|
|
378
|
+
and frame_reductions_left > 0):
|
|
379
|
+
old_nf = payload.get("num_frames", 0)
|
|
380
|
+
new_nf = max(int(old_nf * 0.7), 49)
|
|
381
|
+
logger.warning(
|
|
382
|
+
f"[AgnesVideo] 400 num_frames error ({old_nf} frames), "
|
|
383
|
+
f"reducing to {new_nf} and retrying "
|
|
384
|
+
f"({frame_reductions_left} reductions left)..."
|
|
385
|
+
)
|
|
386
|
+
collect_error(
|
|
387
|
+
"video", "submit_video",
|
|
388
|
+
prompt=payload.get("prompt", ""),
|
|
389
|
+
error_type="NumFramesExceeded",
|
|
390
|
+
error_message=f"HTTP 400: num_frames {old_nf} exceeded, reducing to {new_nf}",
|
|
391
|
+
status_code=400,
|
|
392
|
+
response_body=resp.text,
|
|
393
|
+
retry_count=attempt + 1,
|
|
394
|
+
extra={"mode": mode_desc, "old_nf": old_nf, "new_nf": new_nf},
|
|
395
|
+
)
|
|
396
|
+
payload["num_frames"] = new_nf
|
|
397
|
+
frame_reductions_left -= 1
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
logger.error(f"[AgnesVideo] HTTP {resp.status_code}: {error_text}")
|
|
401
|
+
collect_error(
|
|
402
|
+
"video", "submit_video",
|
|
403
|
+
prompt=payload.get("prompt", ""),
|
|
404
|
+
error_type="HTTPError",
|
|
405
|
+
error_message=f"HTTP {resp.status_code}: {error_text}",
|
|
406
|
+
status_code=resp.status_code,
|
|
407
|
+
response_body=resp.text,
|
|
408
|
+
retry_count=attempt + 1,
|
|
409
|
+
extra={"mode": mode_desc},
|
|
410
|
+
)
|
|
411
|
+
raise RuntimeError(f"Agnes video submit failed (HTTP {resp.status_code}): {error_text}")
|
|
412
|
+
|
|
413
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError,
|
|
414
|
+
asyncio.TimeoutError) as e:
|
|
415
|
+
# 每次失败都记录(包括中间重试)
|
|
416
|
+
collect_error_from_exception(
|
|
417
|
+
"video", "submit_video",
|
|
418
|
+
exc=e, prompt=payload.get("prompt", ""),
|
|
419
|
+
retry_count=attempt + 1,
|
|
420
|
+
extra={"mode": mode_desc},
|
|
421
|
+
)
|
|
422
|
+
if attempt < self.max_retries - 1:
|
|
423
|
+
delay = self.retry_base_delay * (attempt + 1)
|
|
424
|
+
logger.warning(
|
|
425
|
+
f"[AgnesVideo] {type(e).__name__} on {mode_desc}, "
|
|
426
|
+
f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
|
|
427
|
+
)
|
|
428
|
+
await asyncio.sleep(delay)
|
|
429
|
+
continue
|
|
430
|
+
raise
|
|
431
|
+
|
|
432
|
+
collect_error(
|
|
433
|
+
"video", "submit_video",
|
|
434
|
+
prompt=payload.get("prompt", ""),
|
|
435
|
+
error_type="RetriesExhausted",
|
|
436
|
+
error_message=f"{mode_desc}: max retries ({self.max_retries}) exceeded",
|
|
437
|
+
retry_count=self.max_retries,
|
|
438
|
+
extra={"mode": mode_desc},
|
|
439
|
+
)
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
f"[AgnesVideo] {mode_desc}: max retries ({self.max_retries}) exceeded"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
async def generate_single_video(
|
|
445
|
+
self,
|
|
446
|
+
prompt: str,
|
|
447
|
+
reference_image_paths: List[str] = [],
|
|
448
|
+
duration: Optional[int] = None,
|
|
449
|
+
width: int = 1152,
|
|
450
|
+
height: int = 768,
|
|
451
|
+
seed: Optional[int] = None,
|
|
452
|
+
negative_prompt: Optional[str] = None,
|
|
453
|
+
progress_callback=None,
|
|
454
|
+
**kwargs,
|
|
455
|
+
) -> VideoOutput:
|
|
456
|
+
video_id = await self.submit_video(
|
|
457
|
+
prompt=prompt,
|
|
458
|
+
reference_image_paths=reference_image_paths,
|
|
459
|
+
duration=duration,
|
|
460
|
+
width=width,
|
|
461
|
+
height=height,
|
|
462
|
+
seed=seed,
|
|
463
|
+
negative_prompt=negative_prompt,
|
|
464
|
+
**kwargs,
|
|
465
|
+
)
|
|
466
|
+
return await self.wait_for_video(video_id, progress_callback)
|
|
467
|
+
|
|
468
|
+
async def submit_video(
|
|
469
|
+
self,
|
|
470
|
+
prompt: str,
|
|
471
|
+
reference_image_paths: List[str] = [],
|
|
472
|
+
duration: Optional[int] = None,
|
|
473
|
+
width: int = 1152,
|
|
474
|
+
height: int = 768,
|
|
475
|
+
seed: Optional[int] = None,
|
|
476
|
+
negative_prompt: Optional[str] = None,
|
|
477
|
+
**kwargs,
|
|
478
|
+
) -> str:
|
|
479
|
+
num_frames, frame_rate = self._get_frame_config(duration, width, height)
|
|
480
|
+
|
|
481
|
+
payload: dict = {
|
|
482
|
+
"model": self.model,
|
|
483
|
+
"prompt": prompt,
|
|
484
|
+
"width": width,
|
|
485
|
+
"height": height,
|
|
486
|
+
"num_frames": num_frames,
|
|
487
|
+
"frame_rate": frame_rate,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if seed is not None:
|
|
491
|
+
payload["seed"] = seed
|
|
492
|
+
if negative_prompt:
|
|
493
|
+
payload["negative_prompt"] = negative_prompt
|
|
494
|
+
|
|
495
|
+
resolved_refs = []
|
|
496
|
+
for p in reference_image_paths:
|
|
497
|
+
resolved_refs.append(await self._resolve_image_ref(p))
|
|
498
|
+
n_refs = len(resolved_refs)
|
|
499
|
+
|
|
500
|
+
if n_refs == 0:
|
|
501
|
+
mode_desc = "text-to-video"
|
|
502
|
+
elif n_refs == 1:
|
|
503
|
+
payload["image"] = resolved_refs[0]
|
|
504
|
+
payload["mode"] = "ti2vid"
|
|
505
|
+
mode_desc = "image-to-video"
|
|
506
|
+
else:
|
|
507
|
+
payload["extra_body"] = {
|
|
508
|
+
"image": resolved_refs,
|
|
509
|
+
"mode": "keyframes",
|
|
510
|
+
}
|
|
511
|
+
mode_desc = f"keyframes ({n_refs} frames)"
|
|
512
|
+
|
|
513
|
+
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
514
|
+
|
|
515
|
+
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
516
|
+
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
517
|
+
return video_id
|
|
518
|
+
|
|
519
|
+
async def wait_for_video(self, video_id: str, progress_callback=None) -> VideoOutput:
|
|
520
|
+
final = await self._poll_task(video_id, progress_callback=progress_callback)
|
|
521
|
+
|
|
522
|
+
video_url = (
|
|
523
|
+
final.get("remixed_from_video_id")
|
|
524
|
+
or final.get("video_url")
|
|
525
|
+
or final.get("url")
|
|
526
|
+
)
|
|
527
|
+
if not video_url:
|
|
528
|
+
data = final.get("data", {})
|
|
529
|
+
if isinstance(data, dict):
|
|
530
|
+
video_url = data.get("video_url") or data.get("url")
|
|
531
|
+
if not video_url:
|
|
532
|
+
raise RuntimeError(f"Agnes video: no URL in completed task: {final}")
|
|
533
|
+
|
|
534
|
+
logger.info(f"[AgnesVideo] Done: {video_url[:80]}...")
|
|
535
|
+
return VideoOutput(fmt="url", ext="mp4", data=video_url)
|