free-short-video 6.2.1 → 6.4.0

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 (91) hide show
  1. package/.env.example +65 -11
  2. package/README.md +81 -2
  3. package/core/__init__.py +3 -3
  4. package/core/api/__init__.py +1 -1
  5. package/core/api/agnes_chat.py +0 -1
  6. package/core/api/agnes_image.py +16 -7
  7. package/core/api/agnes_models.py +1 -1
  8. package/core/api/agnes_video.py +61 -10
  9. package/core/api/error_collector.py +22 -0
  10. package/core/api/key_manager.py +18 -2
  11. package/core/api/rate_limiter.py +67 -15
  12. package/core/artifacts.py +14 -7
  13. package/core/audio/__init__.py +2 -2
  14. package/core/audio/subtitle/generator.py +19 -3
  15. package/core/audio/subtitle/renderer.py +1 -1
  16. package/core/audio/tashkeel.py +117 -0
  17. package/core/audio/tts.py +1 -1
  18. package/core/audio/voices.py +210 -15
  19. package/core/compositor/concatenator/audio_overlay.py +571 -12
  20. package/core/compositor/concatenator/concat.py +120 -3
  21. package/core/compositor/processor.py +2 -3
  22. package/core/compositor/watermark.py +30 -15
  23. package/core/config.py +132 -11
  24. package/core/dependency_graph.py +0 -1
  25. package/core/pipelines/__init__.py +132 -16
  26. package/core/pipelines/anchor_video.py +9 -9
  27. package/core/pipelines/creative/__init__.py +2 -2
  28. package/core/pipelines/creative/pipeline.py +23 -1
  29. package/core/pipelines/creative/steps_audio.py +29 -11
  30. package/core/pipelines/creative/steps_frames.py +2 -2
  31. package/core/pipelines/creative/steps_script.py +3 -3
  32. package/core/pipelines/creative/steps_video.py +37 -15
  33. package/core/pipelines/manuscript_video.py +176 -99
  34. package/core/pipelines/multi_scene.py +56 -15
  35. package/core/pipelines/poetry_video.py +65 -21
  36. package/core/pipelines/simple_video.py +11 -6
  37. package/core/screenwriter/__init__.py +71 -10
  38. package/core/screenwriter/scenes.py +1 -1
  39. package/core/screenwriter/story.py +24 -8
  40. package/core/screenwriter/style.py +0 -1
  41. package/core/task_manager.py +4 -3
  42. package/images/home.png +0 -0
  43. package/models/__init__.py +17 -17
  44. package/models/task.py +11 -2
  45. package/package.json +1 -1
  46. package/requirements.txt +13 -0
  47. package/resource/fonts/NotoNaskhArabicUI.ttf +0 -0
  48. package/resource/fonts/NotoSansBengali-Regular.ttf +0 -0
  49. package/resource/fonts/NotoSansDevanagari-Regular.ttf +0 -0
  50. package/resource/fonts/NotoSansThai-Regular.ttf +0 -0
  51. package/ruff.toml +21 -0
  52. package/server.py +63 -10
  53. package/static/assets/ar-Co0g8IHi.js +1 -0
  54. package/static/assets/bn-CEnXv-Ci.js +1 -0
  55. package/static/assets/de-DFCb-VQb.js +1 -0
  56. package/static/assets/es-BB_h_gjW.js +1 -0
  57. package/static/assets/fa-1AsF74IR.js +1 -0
  58. package/static/assets/fr-D46wN4E5.js +1 -0
  59. package/static/assets/hi-c8b3jY49.js +1 -0
  60. package/static/assets/id-BHQhGk9v.js +1 -0
  61. package/static/assets/index-B9DuV3Tq.css +1 -0
  62. package/static/assets/index-DA1ElcyA.js +45 -0
  63. package/static/assets/it-WSb6hkMy.js +1 -0
  64. package/static/assets/ja-CnXP4P5V.js +1 -0
  65. package/static/assets/ko-DHM410FB.js +1 -0
  66. package/static/assets/ms-BbdBN1mH.js +1 -0
  67. package/static/assets/nl-ClA4sipR.js +1 -0
  68. package/static/assets/pt-5buuwYmD.js +1 -0
  69. package/static/assets/ru-BeoxxbcJ.js +1 -0
  70. package/static/assets/th-D0e7n9S1.js +1 -0
  71. package/static/assets/tl-Ci8Qz9u5.js +1 -0
  72. package/static/assets/tr-BJqJ4N-5.js +1 -0
  73. package/static/assets/ur-BFgi64hX.js +1 -0
  74. package/static/assets/vi-DkyI43Z4.js +1 -0
  75. package/static/index.html +2 -2
  76. package/utils/image.py +8 -3
  77. package/utils/video.py +6 -2
  78. package/web/app_state.py +49 -3
  79. package/web/deps.py +44 -10
  80. package/web/helpers.py +0 -1
  81. package/web/routes/config_routes.py +16 -7
  82. package/web/routes/health_routes.py +55 -0
  83. package/web/routes/image_routes.py +1 -2
  84. package/web/routes/preview_routes.py +279 -0
  85. package/web/routes/task_creation_routes.py +44 -1
  86. package/web/routes/task_routes.py +41 -12
  87. package/web/routes/video_routes.py +2 -3
  88. package/web/routes/voice_routes.py +0 -1
  89. package/web/routes/workspace_routes.py +0 -1
  90. package/static/assets/index-CNMlDGN1.js +0 -98
  91. package/static/assets/index-CP4jXMrr.css +0 -1
package/.env.example CHANGED
@@ -7,29 +7,83 @@
7
7
  # 3. 重启服务生效(需已安装 python-dotenv;未安装时跳过 .env 读取,
8
8
  # Key 仍可通过环境变量或 Web 设置页 POST /api/config/keys 配置)
9
9
  #
10
- # 完整多 Key / 限速说明见 docs/plans/v5.0/optimization_roadmap.md §1
10
+ # 说明:
11
+ # - 系统环境变量优先级高于 .env(同名时覆盖 .env 中的值)
12
+ # - 注释掉(以 # 开头)的项表示「使用代码内默认值」,无需取消注释
13
+ # - 完整部署方式见 docs/public/getting-started.md
11
14
  # ═══════════════════════════════════════════════════════════════════
12
15
 
13
- # Agnes AI API Key(必填)
16
+
17
+ # ── 1. API Key(必填)──────────────────────────────────────────────
14
18
  # 从 https://platform.agnes-ai.com 获取免费 Key
15
19
  AGNES_API_KEY=your-api-key-here
16
20
 
17
- # 多 Key 轮询(可选,见 optimization_roadmap §1.3)
18
- # 每个 Key 的配额独立,总量 ≈ 20 × Key 数 / 分钟
21
+
22
+ # ── 2. 多 Key 轮询(可选,推荐)────────────────────────────────────
23
+ # 每个 Key 的配额独立,总量 ≈ 20 × Key 数 / 分钟。
24
+ # 命名规则:AGNES_API_KEY_2、AGNES_API_KEY_3 ... 序号依次递增,中间不可断号
25
+ # (断号之后的 Key 不会被加载)。
26
+ # 遇到 429 会自动轮换到下一个 Key;限速配额与并发上限随 Key 数线性放大。
19
27
  # AGNES_API_KEY_2=your-second-api-key
20
28
  # AGNES_API_KEY_3=your-third-api-key
21
29
 
22
- # 限速配额覆盖(次/分钟,默认 = 20 × Key 数 × 0.8 安全系数)
30
+
31
+ # ── 3. 服务地址 ────────────────────────────────────────────────────
32
+ HOST=0.0.0.0
33
+ PORT=8765
34
+
35
+
36
+ # ── 4. 限速(可选)─────────────────────────────────────────────────
37
+ # 共享桶:Chat / 图片生成 / 图片上传 / 结果轮询 共用
38
+ # 默认 = 20 × Key 数 × 0.8 安全系数;仍频繁 429 时可适当调低
23
39
  # AGNES_RATE_LIMIT=160
24
40
 
25
- # 桶容量覆盖(默认 = 4 × Key 数;仅需调节突发并发时使用)
41
+ # 共享桶容量(默认 = 4 × Key 数;仅需调节突发并发时使用)
26
42
  # AGNES_RATE_BURST=32
27
43
 
28
- # 视频提交独立限速(次/分钟,默认 = 1 × Key 数;服务端为全局 1/min 时设 =1)
44
+ # 视频提交独立桶(默认 = 1 × Key 数)
45
+ # 若服务端对视频提交是「全局限 1/min」而非 per-Key,显式设为 1
29
46
  # AGNES_VIDEO_RATE_LIMIT=8
30
47
  # AGNES_VIDEO_RATE_BURST=8
31
48
 
32
- # 可选端点/模型覆盖
33
- # AGNES_BASE_URL=https://apihub.agnes-ai.com/v1
34
- # AGNES_IMAGE_MODEL=agnes-image-2.1-flash
35
- # AGNES_VIDEO_MODEL=agnes-video-v2.0
49
+ # ⚠️ 与并发的联动:任务并发权重上限 = AGNES_RATE_LIMIT // 2(默认 10)
50
+ # 各任务类型权重:简单视频 1 / 创意视频 3 / 稿件视频 4 / 数字人 2 / 诗词 3
51
+ # 若把 AGNES_RATE_LIMIT 调得过低(例如 6),并发上限降到 3,
52
+ # 权重为 4 的稿件类任务会因「权重超过并发上限」而无法启动。
53
+ # 此时请调高该值,或配置多个 API Key。
54
+
55
+
56
+ # ── 5. 图生图模型(可选)───────────────────────────────────────────
57
+ # i2i 默认与 t2i 同模型(agnes-image-2.1-flash);如需回退到 2.0:
58
+ # AGNES_IMAGE_I2I_MODEL=agnes-image-2.0
59
+ #
60
+ # 注:视频 / 图片主模型请通过 Web UI 或 POST /api/config 选择,
61
+ # 不再通过环境变量配置(AGNES_VIDEO_MODEL、AGNES_IMAGE_MODEL、
62
+ # AGNES_BASE_URL 均已废弃,代码中不再读取)。
63
+
64
+
65
+ # ── 6. 运维(可选)─────────────────────────────────────────────────
66
+ # 启动时清理 N 天前的僵尸任务目录(不设置则不执行清理)
67
+ # AGNES_SWEEP_AGE_DAYS=7
68
+
69
+ # 多 Key 管理接口生成 Key id 所用的哈希盐,一般无需修改
70
+ # AGNES_CONFIG_ID_HMAC_KEY=agnes-config-keys-id-v1
71
+
72
+ # 视频任务轮询总超时(秒,默认 1800)
73
+ # AGNES_VIDEO_POLL_TIMEOUT=1800
74
+
75
+ # 字幕合成:=0 关闭 ffmpeg ASS 单链(回退 moviepy 词级动效路径),默认开启
76
+ # AGNES_SUBTITLE_ASS=1
77
+
78
+ # 提示词语言(zh/en,影响 LLM meta-prompt 语言)
79
+ # PROMPT_LANGUAGE=zh
80
+
81
+
82
+ # ── 7. CORS 跨源白名单(可选,供独立本地伴侣工具调用本服务 API)──────────
83
+ # 场景:本地跑一个独立的简化前端(如 agnes-simple-ui,监听 :8787),
84
+ # 从浏览器直接调用本服务的 /api/* 接口。设置后服务仅允许列出的源跨源调用。
85
+ # 默认空 = 不启用 CORS(攻击面不变);同源页面不受影响。
86
+ # AGNES_CORS_ORIGINS=http://localhost:8787,http://127.0.0.1:3000
87
+ #
88
+ # 显式禁用(即使设置了 origins 也不启用中间件):
89
+ # AGNES_CORS_ENABLED=false
package/README.md CHANGED
@@ -1,12 +1,34 @@
1
1
  ---
2
2
 
3
- # What's New in v6.2.1
3
+ # What's New in v6.4.0
4
4
 
5
5
  ## What's New
6
6
 
7
+ ### Features & Improvements
8
+
9
+ - **Preview ("dry-run") endpoints for creative and manuscript tasks** — `POST /api/creative/preview-script` and `POST /api/manuscript/preview-split` let you inspect the generated script or manuscript segments before submitting a full task. Synchronous, no task created. Preview calls share the Chat API rate limiter and have a lightweight in-process concurrency cap (429 + Retry-After) to prevent abuse. (Credit: @Khaled97Sho, PR #33)
10
+ - **Arabic tashkeel for TTS narration** — creative and manuscript pipelines now automatically apply Arabic diacritic marks (harakat) to narration text before TTS synthesis, improving pronunciation quality. Tashkeel only affects TTS input; subtitle text and `narration.txt` remain clean. (Credit: @Khaled97Sho, PR #33)
11
+ - **Manuscript reference images** — per-segment reference images for manuscript tasks are now supported via `reference_images` + `reference_images_map` parameters, matching the same feature used in creative pipelines. (Credit: @Khaled97Sho, PR #33)
12
+ - **Language-aware narration budget** — narration length for non-CJK languages now uses a language-specific characters-per-second rate (Arabic: 4.0, CJK: 5.0, others: 12.0), fixing the "narration too short for non-CJK text" issue. (Credit: @Khaled97Sho, PR #33)
13
+ - **Configurable CORS origins** — the `AGNES_CORS_ORIGINS` environment variable (comma-separated) allows any companion tool to call the core API cross-origin. `AGNES_CORS_ENABLED` can force-enable or disable; auto-detects when origins are set.
14
+ - **China domestic API domain** — the `cn` domain in `AGNES_DOMAIN_MAP` now correctly points to `api.agnes-ai.cn` (the official China service endpoint), updated from the incorrect `apihub.agnes-ai.cn` (international-site fallback). The frontend domain picker reflects the correct hostname.
15
+
16
+ ### Refactoring & Optimizations
17
+
18
+ - **`split_manuscript_text()` extracted as a public utility** — the manuscript-splitting algorithm is now available as a standalone function, used by both the manuscript pipeline and the new preview-split endpoint.
19
+
7
20
  ### Bug Fixes
8
21
 
9
- - **Localized feedback & diagnostic report** — the diagnostic report header, field names and the generated issue (GitHub) template are no longer hard-coded in Chinese. They now respect your UI language (22 languages), so fields, titles and truncation hints in the feedback panel render correctly for non-Chinese users.
22
+ - **Fixed rate-limiter live-lock** — a rare synchronous acquire path race condition could stall all pipelines indefinitely. The token bucket's async path is now the primary path, with the sync path hardened to prevent the live-lock. (Credit: @Khaled97Sho, PR #33)
23
+ - **Fixed image save not persisting** — `POST /api/images/generations` now `await`s the image download, ensuring the generated image is actually written to disk before the response.
24
+ - **Fixed GitHub CodeQL alerts #43 and #47** — path-injection and information-disclosure security alerts resolved.
25
+ - **Fixed PBKDF2-HMAC-SHA256 for config key IDs** — config key IDs are now hashed with a proper key-derivation function instead of weaker hashing.
26
+ - **Fixed Docker Hub Cloudflare WAF blocking** — the Docker Hub overview update script now strips `<script>` blocks and escapes `<` in the JSON payload to bypass the WAF XSS rule.
27
+ - **Fixed China domestic site domain** — `AGNES_DOMAIN_MAP["cn"]` corrected to `api.agnes-ai.cn` per the official Agnes model catalog. (Issue #37)
28
+
29
+ ---
30
+
31
+ **Compatibility notes**: The `core/audio/tashkeel.py` module adds a new optional dependency (`mishkal`). It is installed by default via `requirements.txt`; if missing, tashkeel silently falls back to the original text. Existing task state files are forward-compatible; no migration needed.
10
32
 
11
33
  ---
12
34
 
@@ -35,6 +57,49 @@ free-short-video
35
57
  [![Docker Hub](https://img.shields.io/docker/pulls/lcy362/free-short-video?label=docker%20pulls)](https://hub.docker.com/r/lcy362/free-short-video)
36
58
  [![npm](https://img.shields.io/npm/v/free-short-video?label=npm)](https://www.npmjs.com/package/free-short-video)
37
59
 
60
+ <p align="center">
61
+ <img src="images/home.png" alt="Agnes Video Generator — Free AI Video Generator" width="720">
62
+ </p>
63
+
64
+ <!--
65
+ schema.org structured data for SEO/GEO indexing. GitHub does not execute this script, but the raw JSON-LD is visible to search engines and AI engines that scan repository READMEs.
66
+ -->
67
+ <!--
68
+ <script type="application/ld+json">
69
+ {
70
+ "@context": "https://schema.org",
71
+ "@type": "SoftwareApplication",
72
+ "name": "Agnes Video Generator",
73
+ "alternateName": "Free AI Video Generator",
74
+ "applicationCategory": "MultimediaApplication",
75
+ "operatingSystem": "Linux, macOS, Windows",
76
+ "description": "A completely free, open-source AI video generator. No subscription, no high-end GPU, no usage limits — type a text idea and get narrated, auto-subtitled multi-scene AI videos. Supports text-to-video, image-to-video, keyframes animation, digital anchor and manuscript-to-video.",
77
+ "url": "https://github.com/lcy362/agnes-video-generator",
78
+ "downloadUrl": "https://github.com/lcy362/agnes-video-generator",
79
+ "softwareVersion": "1.0.0",
80
+ "license": "https://opensource.org/licenses/MIT",
81
+ "keywords": "free AI video generator, AI video generation, text to video, AI video creator, open source video generator, AI narration, auto subtitles, multi-scene video, Runway alternative, Pika alternative",
82
+ "offers": {
83
+ "@type": "Offer",
84
+ "price": "0",
85
+ "priceCurrency": "USD"
86
+ },
87
+ "author": {
88
+ "@type": "Person",
89
+ "@id": "https://lichuanyang.top/#author",
90
+ "name": "SandGrid",
91
+ "alternateName": "lcy362",
92
+ "url": "https://lichuanyang.top/",
93
+ "sameAs": [
94
+ "https://github.com/lcy362",
95
+ "https://gitee.com/sandgrid/agnes-video-generator",
96
+ "https://video.lichuanyang.top/"
97
+ ]
98
+ }
99
+ }
100
+ </script>
101
+ -->
102
+
38
103
  > **🌏 Mirror Notice / 镜像说明**
39
104
  > This project is also mirrored on [Gitee](https://gitee.com/sandgrid/agnes-video-generator) for faster access in mainland China. The **GitHub repository is the primary home** of this project — issues, PRs, and stars are managed there.
40
105
  > 本项目在国内 Gitee 设有镜像仓库,便于国内访问加速;**GitHub 为项目主仓库**,Issue / PR / Star 均在 GitHub 提交。
@@ -121,6 +186,20 @@ To be honest, Agnes's video model isn't perfect yet. The generated frames are so
121
186
  | **Watermark** | No watermark | Built-in watermark | Built-in watermark | C2PA metadata | Built-in watermark |
122
187
  | **Usage Limit** | No limit (16 req/min rate limit) | Billed by compute | Billed by generation | Billed by generation | Billed by generation |
123
188
 
189
+ ## ⚙️ Configuration
190
+
191
+ Everything is configured through environment variables — no config file is required. To start from a documented template:
192
+
193
+ ```bash
194
+ cp .env.example .env # then edit AGNES_API_KEY inside
195
+ ```
196
+
197
+ [`.env.example`](.env.example) lists every supported variable with its default value: API key, multi-key rotation, rate limits, port, model overrides, and maintenance options.
198
+
199
+ > **Have more than one API key?** Set `AGNES_API_KEY`, `AGNES_API_KEY_2`, `AGNES_API_KEY_3` … (numbering must be contiguous). Rate-limit quotas scale with your key count, a `429` automatically rotates to the next key, and the concurrency limit scales along with it.
200
+
201
+ Full walkthrough: [Getting Started → Configure API Key](docs/public/getting-started.md).
202
+
124
203
  ## 📚 Documentation
125
204
 
126
205
  - **[Features](docs/public/features.md)** — Creation modes, the completely free AI model chain, AI narration & smart subtitles, flexible creative controls, production-grade reliability, and the multilingual Web UI.
package/core/__init__.py CHANGED
@@ -3,15 +3,15 @@
3
3
  导出所有子包的核心类和工具函数。
4
4
  """
5
5
 
6
- from core.api import AgnesImageAPI, AgnesVideoAPI, AgnesChatAPI
6
+ from core.api import AgnesChatAPI, AgnesImageAPI, AgnesVideoAPI
7
7
  from core.audio import EdgeTTSEngine, SilentTTSEngine, SubtitleGenerator
8
8
  from core.compositor import VideoConcatenator, VideoProcessor
9
9
  from core.pipelines import (
10
10
  BasePipeline,
11
- PipelineShutdown,
12
- SimpleVideoPipeline,
13
11
  CreativeVideoPipeline,
14
12
  ManuscriptVideoPipeline,
13
+ PipelineShutdown,
14
+ SimpleVideoPipeline,
15
15
  )
16
16
 
17
17
  __all__ = [
@@ -1,7 +1,7 @@
1
1
  """core.api — Agnes AI API 调用层"""
2
2
 
3
+ from core.api.agnes_chat import AgnesChatAPI
3
4
  from core.api.agnes_image import AgnesImageAPI, ImageOutput
4
5
  from core.api.agnes_video import AgnesVideoAPI, VideoOutput
5
- from core.api.agnes_chat import AgnesChatAPI
6
6
 
7
7
  __all__ = ["AgnesImageAPI", "ImageOutput", "AgnesVideoAPI", "VideoOutput", "AgnesChatAPI"]
@@ -10,7 +10,6 @@ import logging
10
10
  import mimetypes
11
11
  import os
12
12
  import re
13
- import time
14
13
  from typing import List
15
14
 
16
15
  import requests
@@ -5,7 +5,6 @@ import base64
5
5
  import logging
6
6
  import mimetypes
7
7
  import os
8
- import time
9
8
  from typing import List, Optional
10
9
 
11
10
  import requests
@@ -29,7 +28,16 @@ class ImageOutput:
29
28
  self.ext = ext
30
29
  self.data = data
31
30
 
32
- def save(self, path: str) -> None:
31
+ async def save(self, path: str) -> None:
32
+ """保存图片到 path(异步)。
33
+
34
+ 优化路线图 0.3:URL 下载为同步 requests 流式读取,协程中直接调用会
35
+ 阻塞事件循环;整体下沉到线程池执行。
36
+ """
37
+ await asyncio.to_thread(self._save_sync, path)
38
+
39
+ def _save_sync(self, path: str) -> None:
40
+ """同步保存实现(供线程池调用;同步上下文可直接使用)。"""
33
41
  if self.fmt == "url":
34
42
  download_image(self.data, path)
35
43
  else:
@@ -59,7 +67,8 @@ class AgnesImageAPI:
59
67
  self.api_key = api_key
60
68
  self.model = model
61
69
  # i2i 默认与 t2i 同模型(官方 2.1 同时支持 t2i/i2i);环境变量可回退到 2.0。
62
- env_i2i = os.environ.get("AGNES_IMAGE_I2I_MODEL")
70
+ from core.config import get_settings
71
+ env_i2i = get_settings().agnes_image_i2i_model
63
72
  self.i2i_model = i2i_model or env_i2i or model
64
73
  # 基础 headers(不含 Authorization):每次请求前经 _auth_headers() 注入当前 Key
65
74
  self._base_headers = {
@@ -143,8 +152,8 @@ class AgnesImageAPI:
143
152
  max_rotations = len(ring) * max_retries
144
153
  while attempt < max_retries:
145
154
  try:
146
- # 全局限速:在发起 HTTP 请求前获取令牌
147
- await asyncio.to_thread(get_rate_limiter().acquire)
155
+ # 全局限速:在发起 HTTP 请求前获取令牌(2.3 异步原生)
156
+ await get_rate_limiter().acquire_async()
148
157
  # 动态超时:第一次 120s,后续逐步增加(图像生成较慢,放宽读超时)
149
158
  read_timeout = _READ_TIMEOUT_BASE_SECONDS * (attempt + 1)
150
159
  resp = await asyncio.to_thread(
@@ -175,7 +184,7 @@ class AgnesImageAPI:
175
184
  "image", "generate_single_image",
176
185
  prompt=prompt,
177
186
  error_type="RateLimit429",
178
- error_message=f"HTTP 429: rate limited",
187
+ error_message="HTTP 429: rate limited",
179
188
  status_code=429,
180
189
  response_body=resp.text,
181
190
  retry_count=attempt + 1,
@@ -188,7 +197,7 @@ class AgnesImageAPI:
188
197
  "image", "generate_single_image",
189
198
  prompt=prompt,
190
199
  error_type="RateLimit429",
191
- error_message=f"HTTP 429: retries exhausted",
200
+ error_message="HTTP 429: retries exhausted",
192
201
  status_code=429,
193
202
  response_body=resp.text,
194
203
  retry_count=max_retries,
@@ -9,8 +9,8 @@ import logging
9
9
  import requests
10
10
 
11
11
  from core.config import (
12
- DEFAULT_TEXT_MODEL,
13
12
  DEFAULT_IMAGE_MODEL,
13
+ DEFAULT_TEXT_MODEL,
14
14
  DEFAULT_VIDEO_MODEL,
15
15
  get_agnes_base_url,
16
16
  )
@@ -37,13 +37,56 @@ DURATION_PRESETS = {
37
37
  _UPLOAD_RETRY_BASE_DELAY_SECONDS = 30
38
38
 
39
39
 
40
+ def _adaptive_poll_interval(interval: int, poll_count: int) -> int:
41
+ """优化路线图 1.3:自适应轮询间隔。
42
+
43
+ 此前固定 ``interval``(默认 60s),每个视频平均多等 ~30s 检测延迟。
44
+ 现改为 20s 起步,每 5 次轮询 +5s,上限为调用方 ``interval``;
45
+ 调用方传小间隔(<20,如测试)时保持原样。
46
+ """
47
+ if interval < 20:
48
+ return interval
49
+ return min(interval, 20 + (poll_count // 5) * 5)
50
+
51
+
52
+ class VideoTaskCancelled(RuntimeError):
53
+ """用户停止任务导致的取消(优化路线图 0.2)。
54
+
55
+ 继承 RuntimeError 以保持向后兼容,但语义上区别于可重试的临时错误
56
+ (超时 / 网络 / 5xx):停止必须立即穿透上层重试循环,否则用户点停止后
57
+ 仍要经历 20s/40s 退避才真正停下。
58
+ """
59
+
60
+
61
+ def is_remote_video_failure(exc: BaseException) -> bool:
62
+ """判断是否为「服务端已确认失败」——只有这种情况才可安全丢弃 video_id。
63
+
64
+ 仅当服务端明确返回 ``status=failed``(异常信息含 "Video generation failed:")
65
+ 时为 True。超时、用户取消、网络中断时服务端任务**可能仍在运行**,必须返回
66
+ False 以保留 video_id 供续传,避免重复提交浪费视频配额(1 次/分钟/Key)。
67
+
68
+ 优化路线图 0.2:此前流水线在任何异常下都删除 task.json,导致超时/取消后
69
+ 续传只能重新提交。
70
+ """
71
+ return "Video generation failed:" in str(exc)
72
+
73
+
40
74
  class VideoOutput:
41
75
  def __init__(self, fmt: str, ext: str, data: str):
42
76
  self.fmt = fmt
43
77
  self.ext = ext
44
78
  self.data = data
45
79
 
46
- def save(self, path: str) -> None:
80
+ async def save(self, path: str) -> None:
81
+ """保存视频到 path(异步)。
82
+
83
+ 优化路线图 0.3:URL 下载为同步 requests 流式读取,耗时 5~30s+,
84
+ 此前在协程中直接调用会阻塞事件循环;整体下沉到线程池执行。
85
+ """
86
+ await asyncio.to_thread(self._save_sync, path)
87
+
88
+ def _save_sync(self, path: str) -> None:
89
+ """同步保存实现(供线程池调用;同步上下文可直接使用)。"""
47
90
  if self.fmt == "url":
48
91
  download_video(self.data, path)
49
92
  else:
@@ -158,7 +201,7 @@ class AgnesVideoAPI:
158
201
  },
159
202
  }
160
203
  logger.info(f"[AgnesVideo] Uploading image to hosted URL (attempt {attempt + 1}/{retries})...")
161
- await asyncio.to_thread(get_rate_limiter().acquire)
204
+ await get_rate_limiter().acquire_async(self.shutdown_event)
162
205
  resp = await asyncio.to_thread(
163
206
  requests.post,
164
207
  f"{get_agnes_base_url()}/images/generations",
@@ -250,7 +293,7 @@ class AgnesVideoAPI:
250
293
  while True:
251
294
  # M2: 每次轮询前检查停止信号
252
295
  if self.shutdown_event and self.shutdown_event.is_set():
253
- raise RuntimeError("Video generation cancelled by user")
296
+ raise VideoTaskCancelled("Video generation cancelled by user")
254
297
 
255
298
  elapsed = asyncio.get_event_loop().time() - start_time
256
299
  if elapsed > max_poll_duration:
@@ -270,8 +313,8 @@ class AgnesVideoAPI:
270
313
  try:
271
314
  if poll_count % 10 == 0:
272
315
  logger.info(f"[AgnesVideo] Polling video {video_id[:16]}... (poll #{poll_count + 1}, elapsed {elapsed:.0f}s)")
273
- # 全局限速:每次轮询都消耗一个令牌
274
- await asyncio.to_thread(get_rate_limiter().acquire)
316
+ # 全局限速:每次轮询都消耗一个令牌(2.3 异步原生,停止可打断)
317
+ await get_rate_limiter().acquire_async(self.shutdown_event)
275
318
  # M2: 用 wait_for 包裹以支持取消;429 换 Key 立即重试(轮询也轮转 Key 分摊配额)
276
319
  poll_attempts = 0
277
320
  while True:
@@ -344,7 +387,8 @@ class AgnesVideoAPI:
344
387
  )
345
388
  raise RuntimeError(error_msg)
346
389
 
347
- await asyncio.sleep(interval)
390
+ # 优化路线图 1.3:自适应轮询间隔(20s 起步,每 5 次 +5s,上限 interval)
391
+ await asyncio.sleep(_adaptive_poll_interval(interval, poll_count))
348
392
 
349
393
  async def _submit_with_retry(self, payload: dict, mode_desc: str) -> str:
350
394
  frame_reductions_left = 2 # allow up to 2 frame-count reductions on 400
@@ -354,11 +398,12 @@ class AgnesVideoAPI:
354
398
  max_rotations = len(ring) * self.max_retries
355
399
  while attempt < self.max_retries:
356
400
  if self.shutdown_event and self.shutdown_event.is_set():
357
- raise RuntimeError("Video generation cancelled by user")
401
+ raise VideoTaskCancelled("Video generation cancelled by user")
358
402
  try:
359
403
  logger.info(f"[AgnesVideo] Submitting {mode_desc} (attempt {attempt + 1}/{self.max_retries})...")
360
- # 视频提交独立限速桶(服务端 1/min 硬限制,不与 chat/image 共享配额)
361
- await asyncio.to_thread(get_video_submit_limiter().acquire)
404
+ # 视频提交独立限速桶(服务端 1/min 硬限制,不与 chat/image 共享配额;
405
+ # 2.3 异步原生,停止可打断)
406
+ await get_video_submit_limiter().acquire_async(self.shutdown_event)
362
407
  # M2: 缩短读超时从 180s 到 60s,使 stop() 更快生效
363
408
  resp = await asyncio.wait_for(
364
409
  asyncio.to_thread(
@@ -671,7 +716,13 @@ class AgnesVideoAPI:
671
716
  return video_id
672
717
 
673
718
  async def wait_for_video(self, video_id: str, progress_callback=None) -> VideoOutput:
674
- final = await self._poll_task(video_id, progress_callback=progress_callback)
719
+ # 1.2:轮询总超时可经 AGNES_VIDEO_POLL_TIMEOUT 配置(3.5 RuntimeSettings 收敛)
720
+ from core.config import get_settings
721
+ poll_timeout = get_settings().agnes_video_poll_timeout
722
+ final = await self._poll_task(
723
+ video_id, progress_callback=progress_callback,
724
+ max_poll_duration=poll_timeout,
725
+ )
675
726
 
676
727
  video_url = (
677
728
  final.get("remixed_from_video_id")
@@ -71,6 +71,25 @@ def _get_log_dir() -> Path:
71
71
  return log_dir
72
72
 
73
73
 
74
+ # 优化路线图 1.5b:error_logs 按数量轮转,超过上限后删除最旧文件,
75
+ # 防止长期运行的工作区无限膨胀(诊断端点 _iter_error_logs 全量读取也受影响)。
76
+ _MAX_ERROR_LOGS = 500
77
+
78
+
79
+ def _rotate_error_logs(log_dir: Path) -> None:
80
+ """超过 _MAX_ERROR_LOGS 个文件时,按 mtime 删除最旧的超量文件。"""
81
+ try:
82
+ files = [p for p in log_dir.glob("*.json") if p.is_file()]
83
+ if len(files) <= _MAX_ERROR_LOGS:
84
+ return
85
+ files.sort(key=lambda p: p.stat().st_mtime) # 最旧在前
86
+ for p in files[: len(files) - _MAX_ERROR_LOGS]:
87
+ p.unlink(missing_ok=True)
88
+ logger.info(f"[ErrorCollector] Rotated old error log → {p.name}")
89
+ except OSError:
90
+ pass
91
+
92
+
74
93
  def set_error_task_id(task_id: str) -> None:
75
94
  """设置当前上下文的 task_id(v6.1 二期,诊断关联用)。
76
95
 
@@ -179,6 +198,9 @@ def collect_error(
179
198
  with open(filepath, "w", encoding="utf-8") as f:
180
199
  json.dump(error_data, f, ensure_ascii=False, indent=2)
181
200
 
201
+ # 优化路线图 1.5b:按数量轮转,防止 error_logs 无限膨胀
202
+ _rotate_error_logs(log_dir)
203
+
182
204
  logger.info(f"[ErrorCollector] Error saved → {filepath}")
183
205
  return str(filepath)
184
206
 
@@ -16,6 +16,7 @@
16
16
  import itertools
17
17
  import logging
18
18
  import threading
19
+ from typing import Optional
19
20
 
20
21
  from core.config import get_api_keys
21
22
 
@@ -29,15 +30,30 @@ class KeyRing:
29
30
  self._keys = list(keys)
30
31
  self._count = itertools.count()
31
32
  self._lock = threading.Lock()
33
+ # rotate() 钉住的下一个 Key 索引:rotate 后紧接的 next() 必须返回该 Key
34
+ # (此前 rotate 与 next 共享递增计数,rotate 消费一个序号后 next 取模
35
+ # 又回到原 Key,导致 429 换 Key 重试实际仍用旧 Key)
36
+ self._force_next: Optional[int] = None
32
37
 
33
38
  def next(self) -> str:
34
39
  """轮转取下一个 Key(普通请求调用,均匀分摊)。"""
35
- return self._keys[next(self._count) % len(self._keys)]
40
+ with self._lock:
41
+ if self._force_next is not None:
42
+ idx = self._force_next
43
+ self._force_next = None
44
+ return self._keys[idx]
45
+ return self._keys[next(self._count) % len(self._keys)]
36
46
 
37
47
  def rotate(self) -> str:
38
- """强制切换到下一个 Key(429 换 Key 重试调用)。"""
48
+ """强制切换到下一个 Key(429 换 Key 重试调用)。
49
+
50
+ 递增计数返回下一个 Key,并记录为 ``_force_next``,确保紧随其后的
51
+ ``next()``(即重试请求的 ``_auth_headers()``)真的使用新 Key,
52
+ 之后恢复正常 round-robin。
53
+ """
39
54
  with self._lock:
40
55
  idx = next(self._count) % len(self._keys)
56
+ self._force_next = idx
41
57
  return self._keys[idx]
42
58
 
43
59
  @property