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.
Files changed (48) hide show
  1. package/.dockerignore +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +531 -0
  4. package/bin/cli.js +191 -0
  5. package/core/__init__.py +35 -0
  6. package/core/api/__init__.py +7 -0
  7. package/core/api/agnes_chat.py +294 -0
  8. package/core/api/agnes_image.py +256 -0
  9. package/core/api/agnes_models.py +84 -0
  10. package/core/api/agnes_video.py +535 -0
  11. package/core/api/error_collector.py +215 -0
  12. package/core/api/rate_limiter.py +142 -0
  13. package/core/artifacts.py +574 -0
  14. package/core/audio/__init__.py +9 -0
  15. package/core/audio/subtitle.py +1103 -0
  16. package/core/audio/tts.py +194 -0
  17. package/core/audio/voices.py +330 -0
  18. package/core/compositor/__init__.py +6 -0
  19. package/core/compositor/concatenator.py +653 -0
  20. package/core/compositor/processor.py +124 -0
  21. package/core/compositor/watermark.py +221 -0
  22. package/core/config.py +474 -0
  23. package/core/image_generator.py +8 -0
  24. package/core/path_security.py +58 -0
  25. package/core/pipeline.py +8 -0
  26. package/core/pipelines/__init__.py +466 -0
  27. package/core/pipelines/anchor_video.py +406 -0
  28. package/core/pipelines/creative_video.py +1968 -0
  29. package/core/pipelines/manuscript_video.py +540 -0
  30. package/core/pipelines/multi_scene.py +380 -0
  31. package/core/pipelines/poetry_video.py +504 -0
  32. package/core/pipelines/simple_video.py +143 -0
  33. package/core/screenwriter.py +1860 -0
  34. package/core/task_manager.py +211 -0
  35. package/core/video_generator.py +8 -0
  36. package/models/__init__.py +75 -0
  37. package/models/task.py +548 -0
  38. package/package.json +36 -0
  39. package/requirements.txt +13 -0
  40. package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
  41. package/resource/fonts/STHeitiMedium.ttc +0 -0
  42. package/server.py +2026 -0
  43. package/static/favicon.ico +0 -0
  44. package/static/icon.png +0 -0
  45. package/static/index.html +5277 -0
  46. package/utils/__init__.py +0 -0
  47. package/utils/image.py +36 -0
  48. 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)