free-short-video 6.2.1 → 6.3.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 (88) hide show
  1. package/.env.example +55 -11
  2. package/README.md +83 -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 +61 -14
  12. package/core/artifacts.py +14 -7
  13. package/core/audio/__init__.py +2 -2
  14. package/core/audio/subtitle/generator.py +4 -3
  15. package/core/audio/subtitle/renderer.py +1 -1
  16. package/core/audio/tts.py +1 -1
  17. package/core/audio/voices.py +151 -15
  18. package/core/compositor/concatenator/audio_overlay.py +571 -12
  19. package/core/compositor/concatenator/concat.py +120 -3
  20. package/core/compositor/processor.py +2 -3
  21. package/core/compositor/watermark.py +30 -15
  22. package/core/config.py +113 -10
  23. package/core/dependency_graph.py +0 -1
  24. package/core/pipelines/__init__.py +132 -16
  25. package/core/pipelines/anchor_video.py +9 -9
  26. package/core/pipelines/creative/__init__.py +2 -2
  27. package/core/pipelines/creative/steps_audio.py +3 -2
  28. package/core/pipelines/creative/steps_frames.py +2 -2
  29. package/core/pipelines/creative/steps_script.py +1 -1
  30. package/core/pipelines/creative/steps_video.py +37 -15
  31. package/core/pipelines/manuscript_video.py +41 -35
  32. package/core/pipelines/multi_scene.py +56 -15
  33. package/core/pipelines/poetry_video.py +65 -21
  34. package/core/pipelines/simple_video.py +11 -6
  35. package/core/screenwriter/__init__.py +5 -10
  36. package/core/screenwriter/scenes.py +1 -1
  37. package/core/screenwriter/story.py +1 -1
  38. package/core/screenwriter/style.py +0 -1
  39. package/core/task_manager.py +4 -3
  40. package/images/home.png +0 -0
  41. package/models/__init__.py +17 -17
  42. package/models/task.py +7 -2
  43. package/package.json +1 -1
  44. package/requirements.txt +6 -0
  45. package/resource/fonts/NotoNaskhArabicUI.ttf +0 -0
  46. package/resource/fonts/NotoSansBengali-Regular.ttf +0 -0
  47. package/resource/fonts/NotoSansDevanagari-Regular.ttf +0 -0
  48. package/resource/fonts/NotoSansThai-Regular.ttf +0 -0
  49. package/ruff.toml +21 -0
  50. package/server.py +31 -10
  51. package/static/assets/ar-Co0g8IHi.js +1 -0
  52. package/static/assets/bn-CEnXv-Ci.js +1 -0
  53. package/static/assets/de-DFCb-VQb.js +1 -0
  54. package/static/assets/es-BB_h_gjW.js +1 -0
  55. package/static/assets/fa-1AsF74IR.js +1 -0
  56. package/static/assets/fr-D46wN4E5.js +1 -0
  57. package/static/assets/hi-c8b3jY49.js +1 -0
  58. package/static/assets/id-BHQhGk9v.js +1 -0
  59. package/static/assets/index-B9DuV3Tq.css +1 -0
  60. package/static/assets/index-SVQHMn30.js +45 -0
  61. package/static/assets/it-WSb6hkMy.js +1 -0
  62. package/static/assets/ja-CnXP4P5V.js +1 -0
  63. package/static/assets/ko-DHM410FB.js +1 -0
  64. package/static/assets/ms-BbdBN1mH.js +1 -0
  65. package/static/assets/nl-ClA4sipR.js +1 -0
  66. package/static/assets/pt-5buuwYmD.js +1 -0
  67. package/static/assets/ru-BeoxxbcJ.js +1 -0
  68. package/static/assets/th-D0e7n9S1.js +1 -0
  69. package/static/assets/tl-Ci8Qz9u5.js +1 -0
  70. package/static/assets/tr-BJqJ4N-5.js +1 -0
  71. package/static/assets/ur-BFgi64hX.js +1 -0
  72. package/static/assets/vi-DkyI43Z4.js +1 -0
  73. package/static/index.html +2 -2
  74. package/utils/image.py +8 -3
  75. package/utils/video.py +6 -2
  76. package/web/app_state.py +49 -3
  77. package/web/deps.py +44 -10
  78. package/web/helpers.py +0 -1
  79. package/web/routes/config_routes.py +6 -5
  80. package/web/routes/health_routes.py +55 -0
  81. package/web/routes/image_routes.py +0 -1
  82. package/web/routes/task_creation_routes.py +2 -1
  83. package/web/routes/task_routes.py +41 -12
  84. package/web/routes/video_routes.py +2 -3
  85. package/web/routes/voice_routes.py +0 -1
  86. package/web/routes/workspace_routes.py +0 -1
  87. package/static/assets/index-CNMlDGN1.js +0 -98
  88. package/static/assets/index-CP4jXMrr.css +0 -1
package/.env.example CHANGED
@@ -7,29 +7,73 @@
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
package/README.md CHANGED
@@ -1,12 +1,36 @@
1
1
  ---
2
2
 
3
- # What's New in v6.2.1
3
+ # What's New in v6.3.0
4
4
 
5
5
  ## What's New
6
6
 
7
+ ### Features & Improvements
8
+
9
+ - **Complete v6 optimization roadmap (29/29 items)** — every batch of the v6 roadmap is now shipped:
10
+ - **Performance (batch 2)**: the final compositing chain is now ffmpeg-based — identical-parameter scene concatenation uses `-c copy`, audio alignment/volume/silence-padding merge into a single filter pass, and subtitles render through the ASS path with per-entry styles (`AGNES_SUBTITLE_ASS`, with automatic fallback to the moviepy path). Poetry videos compose all scenes in one pass instead of re-encoding per scene. A dedicated encoding thread pool isolates heavy ffmpeg/moviepy work from API requests, and the token-bucket rate limiter gained a native async path so stopping a task during rate-limit waits is instant.
11
+ - **Reliability & engineering (batch 1)**: task state follows a single-writer principle with per-task locking, resume supports persisted word-level TTS cues (no re-synthesis on resume), video polling is adaptive and multi-scene waits run concurrently, task listing is indexed with `limit/offset/status` pagination, stale artifacts/error logs are governed, and the frontend stops polling in background tabs with exponential backoff and a connection-loss banner.
12
+ - **Frontend & i18n**: translations are split into per-language lazy-loaded chunks — the first-screen JS bundle drops from ~721 kB to ~305 kB (gzip 226 kB → 97 kB, **-58%**). Form submission/confirm/toast flows were unified into shared composables, mobile layout, focus-trap modals, `prefers-reduced-motion` and form drafts were added.
13
+ - **Observability & ops (batch 3)**: new `GET /api/health` and `GET /api/metrics` endpoints, optional rotating file logging (`AGNES_LOG_FILE`), and a Docker `HEALTHCHECK`. Runtime settings are now converged through typed `pydantic-settings` (with `.env` support) so concurrency limits scale dynamically with API-key count.
14
+ - **Immediate defect fixes (batch 0)**: stop now cancels instantly without retry backoff, event-loop blocking (watermark re-encode, sync downloads) is moved off the loop, multi-key delete works correctly, a frontend `v-html` XSS vector is closed, and image generation got a duplicate-submit guard.
15
+ - **Full 22-language support incl. Arabic** — the UI already had 22 languages; this release completes the voice catalog for all of them. Arabic UI is fully supported (PR #32), and 8 UI languages (Turkish, Vietnamese, Thai, Tagalog, Hindi, Persian, Bengali, Urdu) now have edge_tts voice groupings with native-voice name display, script-detection regexes (Thai/Devanagari/Bengali) and per-script subtitle font fallback (new bundled Noto fonts; Persian/Urdu reuse the Arabic reshape+bidi pipeline).
16
+ - **Transparent analytics disclosure & privacy controls** — the settings panel now shows a clear, collapsible privacy card listing exactly what usage statistics are reported (and what is never uploaded: prompts, manuscripts, poems, API keys and reference images are redacted before reporting). Analytics can be turned off entirely from the panel.
17
+ - **Complete error tracebacks in the feedback report** — pipeline failures now persist the full `traceback` into the task state; the diagnostics endpoint and the in-app feedback report include it, so you can paste complete error details (e.g. environment-level `[WinError 2]`) into GitHub issues without checking the server console.
18
+
19
+ ### Refactoring & Optimizations
20
+
21
+ - **ffmpeg-first compositing chain** — the final assembly path for creative/manuscript/anchor/poetry videos was reworked from 3-4 full re-encodes into copy-concat + a single filter pass (with graceful fallback to the previous moviepy path). This is the largest performance win in the v6 line, cutting final-assembly time by roughly 3-10x on typical outputs.
22
+ - **Asynchronous rate limiting with dedicated encoding thread pool** — the token bucket now offers a native async acquire path (stop-aware), and heavy encoding runs on a dedicated executor so long encoding jobs no longer starve the request path.
23
+
7
24
  ### Bug Fixes
8
25
 
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.
26
+ - **Fixed stopping behavior** — cancelling a task no longer triggers retry backoff (up to ~2 minutes) and no longer deletes a resumable `video_id`.
27
+ - **Fixed multi-Key configuration** — key IDs are now hashed from the actual key so deleting one Key from multiple configured Keys removes exactly that Key.
28
+ - **Fixed event-loop freezes** — watermark re-encoding and synchronous downloads no longer block the whole service; a semaphore release bug that could permanently break the concurrency cap under low-rate-limit configurations is fixed.
29
+ - **Fixed frontend issues** — a stored-XSS vector via unescaped `v-html` is closed, duplicate image-submit without guard is prevented, and fetch errors now surface readable backend messages instead of silent failures.
30
+
31
+ ---
32
+
33
+ No configuration changes are required. Existing tasks remain resumable; task state files are unchanged in format.
10
34
 
11
35
  ---
12
36
 
@@ -35,6 +59,49 @@ free-short-video
35
59
  [![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
60
  [![npm](https://img.shields.io/npm/v/free-short-video?label=npm)](https://www.npmjs.com/package/free-short-video)
37
61
 
62
+ <p align="center">
63
+ <img src="images/home.png" alt="Agnes Video Generator — Free AI Video Generator" width="720">
64
+ </p>
65
+
66
+ <!--
67
+ 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.
68
+ -->
69
+ <!--
70
+ <script type="application/ld+json">
71
+ {
72
+ "@context": "https://schema.org",
73
+ "@type": "SoftwareApplication",
74
+ "name": "Agnes Video Generator",
75
+ "alternateName": "Free AI Video Generator",
76
+ "applicationCategory": "MultimediaApplication",
77
+ "operatingSystem": "Linux, macOS, Windows",
78
+ "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.",
79
+ "url": "https://github.com/lcy362/agnes-video-generator",
80
+ "downloadUrl": "https://github.com/lcy362/agnes-video-generator",
81
+ "softwareVersion": "1.0.0",
82
+ "license": "https://opensource.org/licenses/MIT",
83
+ "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",
84
+ "offers": {
85
+ "@type": "Offer",
86
+ "price": "0",
87
+ "priceCurrency": "USD"
88
+ },
89
+ "author": {
90
+ "@type": "Person",
91
+ "@id": "https://lichuanyang.top/#author",
92
+ "name": "SandGrid",
93
+ "alternateName": "lcy362",
94
+ "url": "https://lichuanyang.top/",
95
+ "sameAs": [
96
+ "https://github.com/lcy362",
97
+ "https://gitee.com/sandgrid/agnes-video-generator",
98
+ "https://video.lichuanyang.top/"
99
+ ]
100
+ }
101
+ }
102
+ </script>
103
+ -->
104
+
38
105
  > **🌏 Mirror Notice / 镜像说明**
39
106
  > 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
107
  > 本项目在国内 Gitee 设有镜像仓库,便于国内访问加速;**GitHub 为项目主仓库**,Issue / PR / Star 均在 GitHub 提交。
@@ -121,6 +188,20 @@ To be honest, Agnes's video model isn't perfect yet. The generated frames are so
121
188
  | **Watermark** | No watermark | Built-in watermark | Built-in watermark | C2PA metadata | Built-in watermark |
122
189
  | **Usage Limit** | No limit (16 req/min rate limit) | Billed by compute | Billed by generation | Billed by generation | Billed by generation |
123
190
 
191
+ ## ⚙️ Configuration
192
+
193
+ Everything is configured through environment variables — no config file is required. To start from a documented template:
194
+
195
+ ```bash
196
+ cp .env.example .env # then edit AGNES_API_KEY inside
197
+ ```
198
+
199
+ [`.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.
200
+
201
+ > **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.
202
+
203
+ Full walkthrough: [Getting Started → Configure API Key](docs/public/getting-started.md).
204
+
124
205
  ## 📚 Documentation
125
206
 
126
207
  - **[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
@@ -28,9 +28,9 @@
28
28
 
29
29
  import asyncio
30
30
  import logging
31
- import os
32
31
  import threading
33
32
  import time
33
+ from typing import Optional
34
34
 
35
35
  from core.api.key_manager import get_key_ring
36
36
 
@@ -55,7 +55,10 @@ def _key_count() -> int:
55
55
 
56
56
  def _effective_rate() -> float:
57
57
  """共享桶有效速率 = 单 Key 配额 × Key 数 × 安全系数。"""
58
- limit = int(os.environ.get("AGNES_RATE_LIMIT", str(_KEY_BASE_RATE * _key_count())))
58
+ from core.config import get_settings
59
+ limit = get_settings().agnes_rate_limit
60
+ if limit is None or limit <= 0:
61
+ limit = _KEY_BASE_RATE * _key_count()
59
62
  return limit * _SAFETY_FACTOR
60
63
 
61
64
 
@@ -65,18 +68,29 @@ def _video_submit_rate() -> float:
65
68
  注:若服务端对视频提交是全局限 1/min(而非 per-Key),
66
69
  设置 AGNES_VIDEO_RATE_LIMIT=1 即可,无需改代码。
67
70
  """
68
- limit = int(os.environ.get("AGNES_VIDEO_RATE_LIMIT", str(_VIDEO_SUBMIT_RATE * _key_count())))
71
+ from core.config import get_settings
72
+ limit = get_settings().agnes_video_rate_limit
73
+ if limit is None or limit <= 0:
74
+ limit = _VIDEO_SUBMIT_RATE * _key_count()
69
75
  return limit * _VIDEO_SAFETY_FACTOR
70
76
 
71
77
 
72
78
  def _max_burst() -> int:
73
79
  """共享桶容量随 Key 数上调:4 × Key 数,否则高并发被突发容量卡住。"""
74
- return int(os.environ.get("AGNES_RATE_BURST", str(4 * _key_count())))
80
+ from core.config import get_settings
81
+ burst = get_settings().agnes_rate_burst
82
+ if burst is None or burst <= 0:
83
+ burst = 4 * _key_count()
84
+ return burst
75
85
 
76
86
 
77
87
  def _video_max_burst() -> int:
78
88
  """视频提交桶容量 = 1 × Key 数:允许每 Key 立即提交一次,随后受 1/min 限制。"""
79
- return int(os.environ.get("AGNES_VIDEO_RATE_BURST", str(_key_count())))
89
+ from core.config import get_settings
90
+ burst = get_settings().agnes_video_rate_burst
91
+ if burst is None or burst <= 0:
92
+ burst = _key_count()
93
+ return burst
80
94
 
81
95
 
82
96
  class AgnesRateLimiter:
@@ -111,12 +125,15 @@ class AgnesRateLimiter:
111
125
  self._total_waits = 0
112
126
  self._total_wait_seconds = 0.0
113
127
 
114
- def acquire(self) -> None:
115
- """阻塞式获取一个令牌。
128
+ def _try_acquire(self) -> Optional[float]:
129
+ """尝试获取令牌(线程安全)。返回 None=成功;否则返回需等待秒数。
116
130
 
117
- 如果桶中有令牌,立即消耗并返回。
118
- 否则计算等待时间并 ``time.sleep()`` 直到令牌可用。
131
+ 优化路线图 2.3:等待计算与阻塞解耦,供同步 ``acquire`` 与异步
132
+ ``acquire_async`` 共用;速率 ≤ 0(如 ``AGNES_RATE_LIMIT=0``)时直接
133
+ 放行,避免此前 ``wait_time = 1/0`` 除零崩溃。
119
134
  """
135
+ if self.refill_rate <= 0:
136
+ return None
120
137
  with self._lock:
121
138
  now = time.monotonic()
122
139
  elapsed = now - self.last_refill
@@ -128,15 +145,16 @@ class AgnesRateLimiter:
128
145
 
129
146
  if self.tokens >= 1.0:
130
147
  self.tokens -= 1.0
131
- return
148
+ return None
132
149
 
133
150
  # 需要等待的时间
134
151
  wait_time = (1.0 - self.tokens) / self.refill_rate
135
152
  self.tokens = 0.0
136
153
  # 更新 refill 时间基准,防止 sleep 期间令牌被其他线程"偷走"
137
154
  self.last_refill = now + wait_time
155
+ return wait_time
138
156
 
139
- # sleep 在锁外执行,避免阻塞其他线程的 refill 计算
157
+ def _record_wait(self, wait_time: float) -> None:
140
158
  if wait_time > 0.05:
141
159
  self._total_waits += 1
142
160
  self._total_wait_seconds += wait_time
@@ -145,11 +163,40 @@ class AgnesRateLimiter:
145
163
  f"(累计等待 {self._total_waits} 次, "
146
164
  f"{self._total_wait_seconds:.0f}s)"
147
165
  )
166
+
167
+ def acquire(self) -> None:
168
+ """阻塞式获取一个令牌(同步场景 / 脚本 / 测试用)。
169
+
170
+ 如果桶中有令牌,立即消耗并返回;否则 ``time.sleep()`` 直到令牌可用。
171
+ """
172
+ while True:
173
+ wait_time = self._try_acquire()
174
+ if wait_time is None:
175
+ return
176
+ self._record_wait(wait_time)
148
177
  time.sleep(wait_time)
149
178
 
150
- async def acquire_async(self) -> None:
151
- """异步获取令牌(内部使用 ``asyncio.to_thread``)。"""
152
- await asyncio.to_thread(self.acquire)
179
+ async def acquire_async(self, stop_event: asyncio.Event | None = None) -> None:
180
+ """异步原生获取令牌(优化路线图 2.3)。
181
+
182
+ 等待在事件循环中执行(不再占线程池);可被任务取消打断,或经
183
+ ``stop_event`` 提前放行(停止后调用方不会再发请求,无需令牌)。
184
+ """
185
+ while True:
186
+ wait_time = self._try_acquire()
187
+ if wait_time is None:
188
+ return
189
+ self._record_wait(wait_time)
190
+ remaining = wait_time
191
+ while remaining > 0:
192
+ if stop_event is not None and stop_event.is_set():
193
+ return
194
+ await asyncio.sleep(min(0.1, remaining))
195
+ remaining -= 0.1
196
+ # 预支语义(与同步 acquire 一致):等待完成即视为已获取令牌。
197
+ # 注意不能再调用 _try_acquire()——last_refill 已被推进到
198
+ # now+wait_time,再次计算会因 elapsed≈0 而永远不足。
199
+ return
153
200
 
154
201
  @property
155
202
  def stats(self) -> dict: