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.
- package/.env.example +55 -11
- package/README.md +83 -2
- package/core/__init__.py +3 -3
- package/core/api/__init__.py +1 -1
- package/core/api/agnes_chat.py +0 -1
- package/core/api/agnes_image.py +16 -7
- package/core/api/agnes_models.py +1 -1
- package/core/api/agnes_video.py +61 -10
- package/core/api/error_collector.py +22 -0
- package/core/api/key_manager.py +18 -2
- package/core/api/rate_limiter.py +61 -14
- package/core/artifacts.py +14 -7
- package/core/audio/__init__.py +2 -2
- package/core/audio/subtitle/generator.py +4 -3
- package/core/audio/subtitle/renderer.py +1 -1
- package/core/audio/tts.py +1 -1
- package/core/audio/voices.py +151 -15
- package/core/compositor/concatenator/audio_overlay.py +571 -12
- package/core/compositor/concatenator/concat.py +120 -3
- package/core/compositor/processor.py +2 -3
- package/core/compositor/watermark.py +30 -15
- package/core/config.py +113 -10
- package/core/dependency_graph.py +0 -1
- package/core/pipelines/__init__.py +132 -16
- package/core/pipelines/anchor_video.py +9 -9
- package/core/pipelines/creative/__init__.py +2 -2
- package/core/pipelines/creative/steps_audio.py +3 -2
- package/core/pipelines/creative/steps_frames.py +2 -2
- package/core/pipelines/creative/steps_script.py +1 -1
- package/core/pipelines/creative/steps_video.py +37 -15
- package/core/pipelines/manuscript_video.py +41 -35
- package/core/pipelines/multi_scene.py +56 -15
- package/core/pipelines/poetry_video.py +65 -21
- package/core/pipelines/simple_video.py +11 -6
- package/core/screenwriter/__init__.py +5 -10
- package/core/screenwriter/scenes.py +1 -1
- package/core/screenwriter/story.py +1 -1
- package/core/screenwriter/style.py +0 -1
- package/core/task_manager.py +4 -3
- package/images/home.png +0 -0
- package/models/__init__.py +17 -17
- package/models/task.py +7 -2
- package/package.json +1 -1
- package/requirements.txt +6 -0
- package/resource/fonts/NotoNaskhArabicUI.ttf +0 -0
- package/resource/fonts/NotoSansBengali-Regular.ttf +0 -0
- package/resource/fonts/NotoSansDevanagari-Regular.ttf +0 -0
- package/resource/fonts/NotoSansThai-Regular.ttf +0 -0
- package/ruff.toml +21 -0
- package/server.py +31 -10
- package/static/assets/ar-Co0g8IHi.js +1 -0
- package/static/assets/bn-CEnXv-Ci.js +1 -0
- package/static/assets/de-DFCb-VQb.js +1 -0
- package/static/assets/es-BB_h_gjW.js +1 -0
- package/static/assets/fa-1AsF74IR.js +1 -0
- package/static/assets/fr-D46wN4E5.js +1 -0
- package/static/assets/hi-c8b3jY49.js +1 -0
- package/static/assets/id-BHQhGk9v.js +1 -0
- package/static/assets/index-B9DuV3Tq.css +1 -0
- package/static/assets/index-SVQHMn30.js +45 -0
- package/static/assets/it-WSb6hkMy.js +1 -0
- package/static/assets/ja-CnXP4P5V.js +1 -0
- package/static/assets/ko-DHM410FB.js +1 -0
- package/static/assets/ms-BbdBN1mH.js +1 -0
- package/static/assets/nl-ClA4sipR.js +1 -0
- package/static/assets/pt-5buuwYmD.js +1 -0
- package/static/assets/ru-BeoxxbcJ.js +1 -0
- package/static/assets/th-D0e7n9S1.js +1 -0
- package/static/assets/tl-Ci8Qz9u5.js +1 -0
- package/static/assets/tr-BJqJ4N-5.js +1 -0
- package/static/assets/ur-BFgi64hX.js +1 -0
- package/static/assets/vi-DkyI43Z4.js +1 -0
- package/static/index.html +2 -2
- package/utils/image.py +8 -3
- package/utils/video.py +6 -2
- package/web/app_state.py +49 -3
- package/web/deps.py +44 -10
- package/web/helpers.py +0 -1
- package/web/routes/config_routes.py +6 -5
- package/web/routes/health_routes.py +55 -0
- package/web/routes/image_routes.py +0 -1
- package/web/routes/task_creation_routes.py +2 -1
- package/web/routes/task_routes.py +41 -12
- package/web/routes/video_routes.py +2 -3
- package/web/routes/voice_routes.py +0 -1
- package/web/routes/workspace_routes.py +0 -1
- package/static/assets/index-CNMlDGN1.js +0 -98
- 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
|
-
#
|
|
10
|
+
# 说明:
|
|
11
|
+
# - 系统环境变量优先级高于 .env(同名时覆盖 .env 中的值)
|
|
12
|
+
# - 注释掉(以 # 开头)的项表示「使用代码内默认值」,无需取消注释
|
|
13
|
+
# - 完整部署方式见 docs/public/getting-started.md
|
|
11
14
|
# ═══════════════════════════════════════════════════════════════════
|
|
12
15
|
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
41
|
+
# 共享桶容量(默认 = 4 × Key 数;仅需调节突发并发时使用)
|
|
26
42
|
# AGNES_RATE_BURST=32
|
|
27
43
|
|
|
28
|
-
#
|
|
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
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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.
|
|
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
|
-
- **
|
|
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
|
[](https://hub.docker.com/r/lcy362/free-short-video)
|
|
36
60
|
[](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
|
|
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__ = [
|
package/core/api/__init__.py
CHANGED
|
@@ -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"]
|
package/core/api/agnes_chat.py
CHANGED
package/core/api/agnes_image.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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=
|
|
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=
|
|
200
|
+
error_message="HTTP 429: retries exhausted",
|
|
192
201
|
status_code=429,
|
|
193
202
|
response_body=resp.text,
|
|
194
203
|
retry_count=max_retries,
|
package/core/api/agnes_models.py
CHANGED
package/core/api/agnes_video.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/core/api/key_manager.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/core/api/rate_limiter.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
"""
|
|
128
|
+
def _try_acquire(self) -> Optional[float]:
|
|
129
|
+
"""尝试获取令牌(线程安全)。返回 None=成功;否则返回需等待秒数。
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
152
|
-
|
|
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:
|