free-short-video 5.1.7 → 5.2.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/README.md CHANGED
@@ -20,6 +20,8 @@ free-short-video
20
20
  [![License](https://img.shields.io/github/license/lcy362/agnes-video-generator)](https://github.com/lcy362/agnes-video-generator/blob/main/LICENSE)
21
21
  [![Python](https://img.shields.io/badge/python-3.10+-blue)](https://www.python.org/)
22
22
  [![Website](https://img.shields.io/badge/website-video.lichuanyang.top-8A2BE2)](https://video.lichuanyang.top)
23
+ [![Docker Hub](https://img.shields.io/docker/pulls/lcy362/free-short-video?label=docker%20pulls)](https://hub.docker.com/r/lcy362/free-short-video)
24
+ [![npm](https://img.shields.io/npm/v/free-short-video?label=npm)](https://www.npmjs.com/package/free-short-video)
23
25
 
24
26
  > **Completely free AI video generator** — Built on Agnes AI's free models, no subscription, no high-end GPU, no usage limits. Type in a text idea and automatically generate multi-scene AI videos with narration and subtitles. Supports text-to-video, image-to-video, keyframes animation, digital anchor, and more. All AI compute runs in the cloud — a regular laptop is all you need. **[Try it online →](https://video.lichuanyang.top)**
25
27
 
@@ -96,487 +98,14 @@ To be honest, Agnes's video model isn't perfect yet. The generated frames are so
96
98
  | **Watermark** | No watermark | Built-in watermark | Built-in watermark | C2PA metadata | Built-in watermark |
97
99
  | **Usage Limit** | No limit (16 req/min rate limit) | Billed by compute | Billed by generation | Billed by generation | Billed by generation |
98
100
 
99
- ## ✨ Core Features
101
+ ## 📚 Documentation
100
102
 
101
- ### 🎬 Multiple Creation Modes
102
-
103
- | Mode | Description | Best For |
104
- |------|-------------|----------|
105
- | **Simple Video** | Single prompt → single AI video. Full control over all parameters (generation mode, duration, resolution, seed, negative prompt). Also supports image-to-video and keyframes mode. | Quick single-clip AI video |
106
- | **Creative Video** | Full AI pipeline: idea → story → script → character reference → multi-scene video → narration → subtitles → final output. 10-step pipeline, fully automated. | Storytelling, creative videos |
107
- | **Manuscript Video** | Paste a long article or script → auto-split by reading duration → per-segment AI video → unified TTS narration + subtitle overlay → final output. 5-step pipeline. | Explainers, course content, vlogs |
108
- | **Digital Anchor** | AI-generated digital anchor (or upload custom image) → dynamic anchor clip → TTS narration → subtitle positioning → looped concatenation. Optional reference image for appearance consistency. | Virtual anchors, product presentations, news broadcasts |
109
-
110
- ### 🆓 Completely Free AI Model Chain
111
-
112
- All core AI capabilities are **completely free** — no trial period, no watermarks, no token limits:
113
-
114
- | Capability | Model | Cost |
115
- |-----------|-------|------|
116
- | Text / Script Generation | `agnes-2.0-flash` | Free |
117
- | Image Generation | `agnes-image-2.1-flash` | Free |
118
- | Video Generation | `agnes-video-v2.0` | Free |
119
- | Text-to-Speech Narration | Edge TTS (Microsoft) | Free, no extra API key needed |
120
-
121
- All AI API calls share a global token bucket rate limiter (16 requests/min), with automatic retries and exponential backoff to ensure stable operation.
122
-
123
- ### 🎙️ AI Narration & Smart Subtitles
124
-
125
- Both Creative Video and Manuscript Video support:
126
-
127
- - **Free TTS narration**: Based on Microsoft Edge TTS, offering 4 Chinese voice roles (gentle female, steady male, lively female, young male) with adjustable speech rate (-30% to +30%)
128
- - **Word-level fine-grained subtitles**: SRT subtitles generated from TTS word-level timestamps, one entry every 2-3 seconds, with precise audio-video sync
129
- - **Multi-line auto-wrapping**: Long subtitle text is intelligently split into two lines, preferring punctuation break points to prevent screen overflow
130
- - **Fully configurable subtitle style**: Font, color, size, position (top/bottom), stroke, and semi-transparent background
131
- - **Audio-video sync strategy**: All video clips are concatenated first, then audio and subtitles are overlaid as a whole, avoiding cumulative errors from per-segment overlay. TTS output is automatically amplified 2.5× to compensate for Edge TTS's low default volume
132
-
133
- ### 🎨 Flexible Creative Controls
134
-
135
- - **Custom reference images** — Upload character or scene reference images to maintain visual consistency across scenes
136
- - **Custom end frames** — Specify end frame images per scene for precise visual transition control
137
- - **Image-to-image end frames** — Auto-generate scene end frames via img2img from your reference image
138
- - **Three video chaining modes** — `keyframes` (first+last frame interpolation, recommended) / `ti2vid` (inter-scene transition frames) / `none` (independent scenes)
139
- - **Multiple resolutions** — Portrait 9:16 (768×1152), Landscape 16:9 (1152×768), Square 1:1 (1024×1024)
140
- - **Flexible duration** — Custom scene duration
141
- - **Smart manuscript splitting** — Splits by period/question mark/exclamation mark, greedily merges into 5-12 second segments based on reading speed (~4 chars/sec), preserves long sentences, auto-merges short sentences forward
142
-
143
- ### 🔧 Production-Grade Reliability
144
-
145
- - **Checkpoint resume** — Automatically resumes from the last checkpoint after interruption; state is persisted after each step, no duplicate API calls
146
- - **Task management** — Create, view, resume, and stop tasks from the Web UI
147
- - **Real-time progress** — WebSocket pushes per-step generation progress (step name, status, percentage, current/total)
148
- - **Built-in CJK fonts** — Project ships with Chinese fonts, no garbled characters in subtitle rendering
149
-
150
- ### 🤖 AI Agent Friendly
151
-
152
- Designed specifically for AI coding assistants (Claude, Cursor, QoderWork, etc.), with a complete `AGENTS.md` deployment guide. AI Agents can automatically:
153
-
154
- - Check environment (Python 3.10+, ffmpeg)
155
- - Install dependencies and start the server
156
- - Configure API key
157
- - Run 4-layer deployment verification (connectivity → static analysis → endpoint testing → subtitle feature)
158
- - Execute 10-scenario regression test suite
159
-
160
- ### 🌐 Multilingual Web UI
161
-
162
- One-click launch, operate entirely in the browser. Interface available in **13 languages**: 中文, English, Deutsch, Français, Nederlands, Español, Português, Italiano, Русский, 日本語, 한국어, Bahasa Melayu, Bahasa Indonesia.
163
-
164
- ## 🚀 Quick Start
165
-
166
- ### Prerequisites
167
-
168
- - Python 3.10+
169
- - ffmpeg — required only for **manual setup** (Option A); the Docker (Option B) and npm (Option C) options bundle ffmpeg automatically, so no system ffmpeg install is needed there.
170
-
171
- That's it. No GPU, no large RAM, a regular laptop is all you need.
172
-
173
- ### Option A: Manual Setup
174
-
175
- **Step 1 — Clone & Launch**
176
-
177
- ```bash
178
- git clone https://github.com/lcy362/agnes-video-generator.git
179
- cd agnes-video-generator
180
- ./start.sh
181
- ```
182
-
183
- The script automatically creates a virtual environment, installs dependencies, and opens `http://localhost:8765` in your browser. You can also start manually:
184
-
185
- ```bash
186
- python3 -m venv .venv
187
- .venv/bin/pip install -r requirements.txt
188
- .venv/bin/python server.py
189
- ```
190
-
191
- **Step 2 — Configure API Key**
192
-
193
- Get a free API key from [Agnes AI](https://platform.agnes-ai.com), then choose one of two ways:
194
-
195
- ```bash
196
- # Way 1: Environment variable
197
- export AGNES_API_KEY="your-api-key"
198
-
199
- # Way 2: Via API (same as entering it in the Web UI)
200
- curl -X POST http://localhost:8765/api/config \
201
- -H "Content-Type: application/json" \
202
- -d '{"api_key": "your-api-key"}'
203
- ```
204
-
205
- **Step 3 — Create Your First Video**
206
-
207
- Open `http://localhost:8765`, choose a video mode (Simple / Creative / Manuscript / Anchor), enter your idea, and click "Start Generating".
208
-
209
- ### Option B: Docker (No Python/FFmpeg Required)
210
-
211
- Pre-built multi-arch images (`linux/amd64`, `linux/arm64`) are published to both **GitHub Container Registry (GHCR)** and **Docker Hub** on every release.
212
-
213
- **Pull & Run**
214
-
215
- ```bash
216
- # GHCR
217
- docker run -d -p 8765:8765 \
218
- -e AGNES_API_KEY=<your-key> \
219
- -v ~/agnes-data/working:/app/.working_dir \
220
- -v ~/agnes-data/config:/app/.agnes_config \
221
- ghcr.io/lcy362/free-short-video:latest
222
-
223
- # Docker Hub
224
- docker run -d -p 8765:8765 \
225
- -e AGNES_API_KEY=<your-key> \
226
- -v ~/agnes-data/working:/app/.working_dir \
227
- -v ~/agnes-data/config:/app/.agnes_config \
228
- lcy362/free-short-video:latest
229
- ```
230
-
231
- Then open `http://localhost:8765`.
232
-
233
- **Data Persistence:** The app writes videos, uploads, and settings inside the container (`/app/.working_dir`, `/app/.agnes_config`). Mount them to your host so outputs survive container recreation and are accessible from your local filesystem. Your generated videos will be at `~/agnes-data/working/` on your machine.
234
-
235
- Or use `docker compose` with the included `docker-compose.yml`:
236
-
237
- ```bash
238
- git clone https://github.com/lcy362/agnes-video-generator.git
239
- cd agnes-video-generator
240
- AGNES_API_KEY=<your-key> docker compose up -d
241
- ```
242
-
243
- ### Option C: npm (One Command)
244
-
245
- If you have **Node.js 18+** and **Python 3.10+** installed, the whole service ships as an npm package — no cloning, no manual venv:
246
-
247
- ```bash
248
- # Run directly without installing
249
- npx free-short-video
250
-
251
- # Or install globally, then run
252
- npm install -g free-short-video
253
- free-short-video # short alias: fsv
254
- ```
255
-
256
- On first run the launcher automatically creates a local virtual environment, installs Python dependencies, wires up a bundled `ffmpeg` (via `imageio-ffmpeg`, so no system ffmpeg needed), starts the server on `http://localhost:8765`, and opens your browser. Pass your key through the environment or set it later in the Web UI:
257
-
258
- ```bash
259
- AGNES_API_KEY=<your-key> npx free-short-video
260
- ```
261
-
262
- Options: `--port <n>`, `--host <h>` (use `0.0.0.0` for LAN access), `--no-open`.
263
-
264
- #### ffmpeg: bundled by default, or install your own
265
-
266
- With the npm package you normally **don't need to install ffmpeg yourself** — the launcher (`bin/cli.js`) automatically installs `imageio-ffmpeg` (a static ffmpeg binary, now an explicit dependency in `requirements.txt`) into the local venv and prepends its directory to `PATH`, so every `ffmpeg` call inside the Python service resolves to the bundled binary. This works out of the box on macOS, Linux, and Windows.
267
-
268
- **If you prefer to install ffmpeg on your system** (recommended for production / maximum stability — your system ffmpeg takes precedence over the bundled one because it appears earlier on `PATH`):
269
-
270
- ```bash
271
- # macOS
272
- brew install ffmpeg
273
-
274
- # Ubuntu / Debian
275
- sudo apt update && sudo apt install ffmpeg
276
-
277
- # CentOS / RHEL (requires RPM Fusion)
278
- sudo dnf install ffmpeg
279
-
280
- # Windows (Chocolatey)
281
- choco install ffmpeg
282
-
283
- # Windows (Scoop)
284
- scoop install ffmpeg
285
- ```
286
-
287
- Or download a build from <https://ffmpeg.org/download.html> and add it to your `PATH`. Verify with:
288
-
289
- ```bash
290
- ffmpeg -version
291
- ```
292
-
293
- **Risks if you do NOT install a system ffmpeg (i.e. rely solely on the bundled `imageio-ffmpeg`):**
294
-
295
- - **Platform / architecture support** — `imageio-ffmpeg` ships pre-built binaries only for common platforms (macOS x86_64/arm64, Linux x86_64/arm64, Windows x64). On niche or very old architectures a matching wheel may not exist, and the bundled binary would be missing.
296
- - **Single source of truth** — all ffmpeg capability comes from that one static binary. If its install/extract fails (disk permissions, corruption), the failure only surfaces **when you generate a video**, not at server startup — the error is a low-level `FileNotFoundError: 'ffmpeg'`, which is harder to diagnose than a startup check.
297
- - **Pinned version** — the bundled ffmpeg is locked to whatever version `imageio-ffmpeg` ships (e.g. ffmpeg 7.1); you can't easily upgrade it on your own.
298
- - **Mitigation** — for production or stability-critical use, install a system ffmpeg as shown above; the bundled one then acts only as a fallback.
299
-
300
- ### Option D: AI Agent Assisted Setup
301
-
302
- This project is designed for AI coding assistants. First, download the code and prepare your API key:
303
-
304
- ```bash
305
- git clone https://github.com/lcy362/agnes-video-generator.git
306
- cd agnes-video-generator
307
- ```
308
-
309
- Then tell your agent:
310
-
311
- > "Read the AGENTS.md in this project, install dependencies, configure the API key `<your-key>`, and start the server."
312
-
313
- The agent will read `AGENTS.md` (a comprehensive deployment guide) and handle: environment checks (Python 3.10+, ffmpeg), `pip install`, server launch, and API key configuration. After startup, you can also ask the agent to verify the deployment:
314
-
315
- > "Run the deployment verification checks."
316
-
317
- The agent will execute the 4-layer checklist from `AGENTS.md` (connectivity → static analysis → endpoint testing → subtitle feature) and report results.
318
-
319
- ## 📖 Usage
320
-
321
- ### 1. Configure API Key
322
-
323
- Enter your free [Agnes AI](https://platform.agnes-ai.com) API key at the top of the page and save it. Or set it via environment variable:
324
-
325
- ```bash
326
- export AGNES_API_KEY="your-api-key"
327
- ```
328
-
329
- ### 2. Choose a Video Mode
330
-
331
- #### Simple Video
332
-
333
- Quick single-clip generation with full parameter control:
334
-
335
- | Field | Description |
336
- |-------|-------------|
337
- | Prompt | Describe the AI video scene in natural language |
338
- | Generation Mode | Text-to-Video / Image-to-Video / Text+Image / Keyframes |
339
- | Resolution | Portrait 9:16 / Landscape 16:9 / Square 1:1 |
340
- | Duration | 5s / 10s / 15s / 18s / 20s |
341
- | Reference Image | Optional upload for image-to-video modes |
342
- | End Frame Image | Optional end frame for keyframes mode |
343
-
344
- #### Creative Video
345
-
346
- AI-driven multi-scene storytelling:
347
-
348
- | Field | Description | Required |
349
- |-------|-------------|----------|
350
- | Idea | Describe your AI video concept | Yes |
351
- | User Requirements | Scene count, duration, and other constraints | - |
352
- | Visual Style | Cinematic realism, anime, cyberpunk, etc. | - |
353
- | Chaining Mode | keyframes (recommended) / ti2vid / none | - |
354
- | Narration | Enable/disable TTS narration, choose voice and speed | - |
355
- | Subtitle Style | Font, color, size, position, stroke, background | - |
356
- | Reference Image | Optional character reference for visual consistency | - |
357
- | End Frames | Custom or auto-generated per-scene end frames | - |
358
-
359
- #### Manuscript Video
360
-
361
- Long-form text to narrated video:
362
-
363
- | Field | Description | Required |
364
- |-------|-------------|----------|
365
- | Manuscript Text | Paste your full article, script, or narration | Yes |
366
- | Resolution | Portrait / Landscape / Square | - |
367
- | Narration | Voice role and speech rate | - |
368
- | Subtitle Style | Full subtitle customization | - |
369
-
370
- > **Note**: Segment duration is auto-calculated based on text length (~4 chars/sec, 5–12s per segment) — no manual setting needed.
371
-
372
- #### Digital Anchor
373
-
374
- | Field | Description | Required |
375
- |-------|-------------|----------|
376
- | Anchor Script | Enter the text the anchor will say | Yes |
377
- | Anchor Image | AI-generated or upload custom reference image | - |
378
- | Resolution | Portrait / Landscape / Square | - |
379
- | Narration | Voice role and speech rate | - |
380
- | Subtitle Style | Full subtitle customization | - |
381
-
382
- ### 3. Click "Start Generating"
383
-
384
- The progress panel shows real-time generation status for each step. For Creative Video: Init → Image Analysis → Story → Character Reference → Script → Narration → End Frame Prompts → End Frame Generation → Video Generation → Audio & Subtitles → Concatenation.
385
-
386
- ### 4. Checkpoint Resume & Task Management
387
-
388
- If the server is interrupted, restart it and find the incomplete task in the "Task List" tab. Click "Resume" to continue from the last checkpoint. Running tasks can also be stopped and resumed later.
389
-
390
- ## 🏗️ Project Structure
391
-
392
- ```
393
- agnes-video-generator/
394
- ├── start.sh # One-click launch script
395
- ├── requirements.txt # Python dependencies
396
- ├── Dockerfile # Multi-arch Docker image (Python 3.11 + ffmpeg via imageio)
397
- ├── docker-compose.yml # Docker Compose with persisted volumes
398
- ├── docker-run.sh # One-command Docker launch (wrapper with bind mounts)
399
- ├── server.py # FastAPI server (REST + WebSocket)
400
- ├── static/
401
- │ └── index.html # Frontend SPA — 5 task tabs, 13 languages (Tailwind CSS)
402
- ├── core/
403
- │ ├── config.py # API key, font resolution, default configs
404
- │ ├── screenwriter.py # Screenwriter Agent (LLM-powered story/script/narration)
405
- │ ├── task_manager.py # Task state persistence & checkpoint resume
406
- │ ├── api/
407
- │ │ ├── agnes_chat.py # LLM Chat API (agnes-2.0-flash)
408
- │ │ ├── agnes_image.py # Image generation API (agnes-image-2.1-flash / 2.0-flash)
409
- │ │ ├── agnes_video.py # Video generation API (agnes-video-v2.0)
410
- │ │ └── rate_limiter.py # Global token bucket rate limiter (16 requests/min)
411
- │ ├── audio/
412
- │ │ ├── tts.py # Edge TTS engine + silent fallback engine
413
- │ │ └── subtitle.py # SRT generation (fine-grained word-level) + overlay
414
- │ ├── compositor/
415
- │ │ ├── concatenator.py # Video concatenation + audio/subtitle overlay
416
- │ │ └── processor.py # Video resize, frame extraction, freeze, silence gen
417
- │ └── pipelines/
418
- │ ├── simple_video.py # Pipeline: Simple Video
419
- │ ├── creative_video.py # Pipeline: Creative Video (10-step)
420
- │ ├── manuscript_video.py # Pipeline: Manuscript Video (5-step)
421
- │ └── anchor_video.py # Pipeline: Digital Anchor
422
- ├── models/
423
- │ └── task.py # Data models (5 task types, configs, requests)
424
- ├── resource/
425
- │ └── fonts/ # Built-in CJK fonts for subtitle rendering
426
- ├── utils/
427
- │ ├── image.py # Image download / base64 conversion
428
- │ └── video.py # Video download
429
- ├── scripts/
430
- │ └── regression_runner.py # 10-scenario regression test suite
431
- └── docs/
432
- ├── regression_test_plan.md # Regression test plan
433
- ├── plans-v1.0/ # v1.0 design & planning docs
434
- ├── plans-v2.0/ # v2.0 review & optimization docs
435
- └── plans-v3.0/ # v3.0 feature planning docs
436
- ```
437
-
438
- ## 🔧 Tech Stack
439
-
440
- | Layer | Choice | Notes |
441
- |-------|--------|-------|
442
- | Backend | Python FastAPI | Async + WebSocket |
443
- | Frontend | HTML/CSS/JS + Tailwind CSS CDN | Zero build steps, single-file SPA |
444
- | LLM | Agnes Chat (`agnes-2.0-flash`) | Free — story, script, narration generation |
445
- | Image AI | `agnes-image-2.1-flash` (t2i) / `agnes-image-2.0-flash` (i2i) | Free — reference images, end frames, standalone image generation |
446
- | Video AI | `agnes-video-v2.0` | Free — text-to-video, image-to-video, keyframes |
447
- | TTS | Edge TTS (Microsoft) | Free — 4 Chinese voices, no extra API key needed |
448
- | Subtitles | moviepy + srt | Fine-grained word-level SRT, multi-line wrapping |
449
- | Video Processing | moviepy + ffmpeg | Concatenation, subtitle overlay, audio mixing |
450
-
451
- ## 🎬 Three AI Video Chaining Modes
452
-
453
- | Mode | How It Works | Best For |
454
- |------|-------------|----------|
455
- | **keyframes** | Specify first + last frame per scene; server auto-interpolates transitions | Smooth transitions (recommended) |
456
- | **ti2vid** | Last frame of previous scene → img2img transition → first frame of next scene | Visual continuity between scenes |
457
- | **none** | All scenes share the same reference image, independent of each other | Fast output, independent scenes |
458
-
459
- ## 📋 API Endpoints
460
-
461
- | Method | Path | Description |
462
- |--------|------|-------------|
463
- | GET | `/` | Serve Web UI |
464
- | GET | `/api/config` | Get API key (masked) |
465
- | POST | `/api/config` | Save API key |
466
- | DELETE | `/api/config` | Delete API key |
467
- | GET | `/api/voices` | List available TTS voices |
468
- | POST | `/api/image/generate` | Image generation |
469
- | GET | `/api/image/{task_id}` | Query image task status |
470
- | POST | `/api/tasks/simple` | Create simple video task |
471
- | POST | `/api/tasks/creative` | Create creative video task |
472
- | POST | `/api/tasks/manuscript` | Create manuscript video task |
473
- | POST | `/api/tasks/anchor` | Create digital anchor task |
474
- | POST | `/api/tasks` | Generic task creation (backward-compatible) |
475
- | GET | `/api/tasks` | List all tasks (with type badges) |
476
- | GET | `/api/tasks/{id}` | Get task details |
477
- | POST | `/api/tasks/{id}/resume` | Resume an interrupted task |
478
- | POST | `/api/tasks/{id}/stop` | Stop a running task |
479
- | GET | `/api/video/{id}` | Download/stream final video |
480
- | WS | `/ws/{id}` | WebSocket real-time progress |
481
-
482
- ## ⚠️ Important Notes
483
-
484
- This project is in early stage — corner cases may not be fully handled. Recommended workflow:
485
-
486
- 1. Fill in your idea on the page and submit an AI video task
487
- 2. Watch the **console logs** (the terminal running `server.py`) and be patient
488
- 3. All key operations are logged for easy debugging
489
-
490
- ### Log Reference
491
-
492
- All important operations are logged to the server console:
493
-
494
- | Prefix | Module |
495
- |--------|--------|
496
- | `[Startup]` | Server startup, stale task reset |
497
- | `[WS]` | WebSocket connect/disconnect |
498
- | `[Resume]` / `[Stop]` | Task resume/stop |
499
- | `[Pipeline]` / `[Simple]` / `[Manuscript]` | Pipeline step execution |
500
- | `[TTS]` / `[Subtitle]` | Audio and subtitle generation |
501
- | `[Compositor]` | Video concatenation and processing |
502
- | `[AgnesImage]` / `[AgnesVideo]` / `[AgnesChat]` | AI API calls |
503
- | `[RateLimiter]` | Global rate limiter |
504
- | `[TaskManager]` | Task state persistence |
505
- | `[Screenwriter]` | Screenwriter Agent |
506
-
507
- ### Output Directory
508
-
509
- All AI video task artifacts are stored under `.working_dir/{timestamp}_{task_id}/`:
510
-
511
- ```
512
- .working_dir/{timestamp}_{task_id}/
513
- ├── task_state.json # Task state (required for checkpoint resume)
514
- ├── final_video.mp4 # Final video with narration + subtitles
515
- ├── story.txt # AI-generated story (creative mode)
516
- ├── script.json # Scene script (JSON format)
517
- ├── narration.mp3 # Combined TTS narration audio
518
- ├── narration.srt # Combined subtitle file
519
- ├── scene_0/
520
- │ ├── video.mp4 # Scene 0 AI video
521
- │ ├── end_frame.png # Scene 0 end frame
522
- │ └── task.json # Video generation task ID
523
- ├── scene_1/
524
- │ └── ...
525
- └── scene_2/
526
- └── ...
527
- ```
528
-
529
- ## 🙏 Acknowledgments
530
-
531
- This project is built upon the following open-source projects:
532
-
533
- - [ViMax](https://github.com/HKUDS/ViMax) — AI video generation framework by HKU Data Science Lab
534
- - [vimax-agnes](https://github.com/easyeye163/vimax-agnes) — Agnes AI adaptation based on ViMax
535
-
536
- Special thanks to [Agnes AI](https://platform.agnes-ai.com) for providing **completely free**, high-quality AI model APIs (text, image, and video generation) — this project runs at absolute zero cost thanks to their generosity.
537
-
538
- ## 📄 License
539
-
540
- MIT
541
-
542
- ---
543
-
544
- ## ❓ FAQ
545
-
546
- ### Is Agnes Video Generator really free? Are there any hidden costs?
547
-
548
- Yes, it is **completely free**. All AI model calls (Agnes Chat, Agnes Image, Agnes Video) are free of charge with no trial period, no watermarks, and no usage limits. The only TTS integration (Microsoft Edge TTS) is also free and requires no extra API key. You only need a free API key from [Agnes AI](https://platform.agnes-ai.com) to get started.
549
-
550
- ### Do I need a GPU to run this AI video generator?
551
-
552
- No. All AI compute runs in the cloud via Agnes AI's free API. You just need a regular laptop or desktop computer that can run Python 3.10+ and ffmpeg. No GPU, no high RAM, no special hardware required.
553
-
554
- ### How is this different from Runway, Pika, or Sora?
555
-
556
- Unlike commercial AI video tools that charge $10–$95/month, Agnes Video Generator is completely free and open-source (MIT). It offers built-in multi-scene pipelines, AI narration, auto subtitles, and digital anchor — features that require third-party tools or manual editing elsewhere. See the [comparison table](#comparison-agnes-vs-commercial-ai-video-tools) above for details.
557
-
558
- ### What video generation modes are supported?
559
-
560
- Four modes: **Simple Video** (single prompt, full parameter control), **Creative Video** (AI story → multi-scene video with narration), **Manuscript Video** (long text → auto-split → narrated video), and **Digital Anchor** (AI anchor with TTS). Additional options include text-to-video, image-to-video, keyframes animation, and image-to-image end frame generation.
561
-
562
- ### Can I use my own images as references?
563
-
564
- Yes. You can upload reference images for character or scene consistency across scenes, use custom end frames for precise visual transitions, or choose img2img to auto-generate end frames from your reference. Reference images are supported in both Creative Video and Digital Anchor modes.
565
-
566
- ### What languages does the UI support?
567
-
568
- The Web UI supports 13 languages: 中文, English, Deutsch, Français, Nederlands, Español, Português, Italiano, Русский, 日本語, 한국어, Bahasa Melayu, and Bahasa Indonesia. Subtitles are generated in the source text language with CJK font support built-in.
569
-
570
- ### Can I run this with Docker?
571
-
572
- Yes. Pre-built images are published to both [GHCR](https://github.com/lcy362/agnes-video-generator/pkgs/container/free-short-video) and [Docker Hub](https://hub.docker.com/r/lcy362/free-short-video). Just pull the `latest` tag and run — no Python or ffmpeg installation needed. See **[Option B: Docker](#option-b-docker-no-pythonffmpeg-required)** in Quick Start for the full command and volume mount instructions.
573
-
574
- ### Can I host this on my own server?
575
-
576
- Absolutely. The project is designed for self-hosting. Just clone the repo, run `./start.sh`, and the server starts on `http://localhost:8765`. No external dependencies, no cloud lock-in. See the [Quick Start](#🚀-quick-start) section above.
577
-
578
- ### How do I get help or report issues?
579
-
580
- Check the [GitHub Issues](https://github.com/lcy362/agnes-video-generator/issues) page for existing reports or open a new one. The project also includes a comprehensive `AGENTS.md` for AI-agent-assisted debugging. For feature requests, bug reports, or questions, the Issues page is the best place.
103
+ - **[Features](docs/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.
104
+ - **[Getting Started](docs/getting-started.md)** — Install and deploy in 4 ways: Manual (`start.sh`), Docker, npm (`npx free-short-video`), or AI-Agent assisted.
105
+ - **[Usage Guide](docs/usage.md)** — Configure your API key, pick a video mode, resume from checkpoints, the three chaining modes, and logs & output layout.
106
+ - **[Architecture](docs/architecture.md)** — Project structure and tech stack.
107
+ - **[API Reference](docs/api.md)** — Full REST + WebSocket endpoint list.
108
+ - **[FAQ](docs/faq.md)** — Frequently asked questions.
109
+ - **[About & License](docs/about.md)** — Acknowledgments and the MIT license.
581
110
 
582
111
  **Keywords**: free AI video generator, AI video generation tool, text to video AI, free AI video maker, AI video creator, open source video generator, Agnes AI, text-to-video, image-to-video, keyframes video, AI narration, auto subtitles, multi-scene video, zero cost AI video, no subscription AI video tool, digital anchor, self-hosted AI video generator, open source alternative to Runway
@@ -17,11 +17,10 @@ import requests
17
17
 
18
18
  from core.api.error_collector import collect_error, collect_error_from_exception
19
19
  from core.api.rate_limiter import get_rate_limiter
20
+ from core.config import get_agnes_base_url
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
- BASE_URL = "https://apihub.agnes-ai.com/v1"
24
-
25
24
  # 重试配置
26
25
  _MAX_RETRIES = 3
27
26
  _RETRY_BASE_DELAY = 15 # 秒,指数退避基数
@@ -111,7 +110,7 @@ class AgnesChatAPI:
111
110
  try:
112
111
  get_rate_limiter().acquire()
113
112
  resp = requests.post(
114
- f"{BASE_URL}/chat/completions",
113
+ f"{get_agnes_base_url()}/chat/completions",
115
114
  headers=self.headers,
116
115
  json=payload,
117
116
  timeout=timeout,
@@ -12,12 +12,11 @@ import requests
12
12
 
13
13
  from core.api.error_collector import collect_error, collect_error_from_exception
14
14
  from core.api.rate_limiter import get_rate_limiter
15
+ from core.config import get_agnes_base_url
15
16
  from utils.image import download_image
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
19
- BASE_URL = "https://apihub.agnes-ai.com/v1"
20
-
21
20
 
22
21
  class ImageOutput:
23
22
  def __init__(self, fmt: str, ext: str, data: str):
@@ -119,7 +118,7 @@ class AgnesImageAPI:
119
118
  read_timeout = 60 * (attempt + 1)
120
119
  resp = await asyncio.to_thread(
121
120
  requests.post,
122
- f"{BASE_URL}/images/generations",
121
+ f"{get_agnes_base_url()}/images/generations",
123
122
  headers=self.headers,
124
123
  json=payload,
125
124
  timeout=(30, read_timeout),
@@ -8,19 +8,15 @@ import logging
8
8
 
9
9
  import requests
10
10
 
11
- from core.api.agnes_chat import BASE_URL
12
11
  from core.config import (
13
12
  DEFAULT_TEXT_MODEL,
14
13
  DEFAULT_IMAGE_MODEL,
15
14
  DEFAULT_VIDEO_MODEL,
15
+ get_agnes_base_url,
16
16
  )
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
- # 带 beta 开关的模型列表端点:裸 /v1/models 会被网关过滤掉内测模型,
21
- # 加 ?all=true(等价 X-Beta-Access: true / ?include_beta=true)才会返回全量(含 2.5-flash)。
22
- MODELS_ENDPOINT = f"{BASE_URL}/models?all=true"
23
-
24
20
  REQUEST_TIMEOUT = 20
25
21
 
26
22
  # 分组失败时的兜底列表
@@ -56,8 +52,9 @@ def fetch_available_models(api_key: str) -> dict:
56
52
  接口失败(网络/鉴权/非 200)时返回硬编码兜底列表。
57
53
  """
58
54
  try:
55
+ endpoint = f"{get_agnes_base_url()}/models?all=true"
59
56
  resp = requests.get(
60
- MODELS_ENDPOINT,
57
+ endpoint,
61
58
  headers={"Authorization": f"Bearer {api_key}"},
62
59
  timeout=REQUEST_TIMEOUT,
63
60
  )
@@ -13,13 +13,11 @@ import requests
13
13
 
14
14
  from core.api.error_collector import collect_error, collect_error_from_exception
15
15
  from core.api.rate_limiter import get_rate_limiter
16
+ from core.config import get_agnes_base_url, get_agnes_api_root
16
17
  from utils.video import download_video
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
- BASE_URL = "https://apihub.agnes-ai.com/v1"
21
- API_ROOT = "https://apihub.agnes-ai.com"
22
-
23
21
  DURATION_PRESETS = {
24
22
  5: (121, 24),
25
23
  10: (241, 24),
@@ -82,7 +80,7 @@ class AgnesVideoAPI:
82
80
  _URL_CACHE_MAX_AGE = 3600 # 1 小时
83
81
  if os.path.exists(url_file):
84
82
  try:
85
- with open(url_file, "r") as f:
83
+ with open(url_file, "r", encoding="utf-8") as f:
86
84
  cache_data = json.loads(f.read())
87
85
  cached_url = cache_data.get("url", "")
88
86
  cached_ts = cache_data.get("ts", 0)
@@ -107,7 +105,7 @@ class AgnesVideoAPI:
107
105
  try:
108
106
  cache_data = {"url": url, "ts": time.time()}
109
107
  tmp_file = url_file + ".tmp"
110
- with open(tmp_file, "w") as f:
108
+ with open(tmp_file, "w", encoding="utf-8") as f:
111
109
  json.dump(cache_data, f)
112
110
  f.flush()
113
111
  os.fsync(f.fileno())
@@ -140,7 +138,7 @@ class AgnesVideoAPI:
140
138
  await asyncio.to_thread(get_rate_limiter().acquire)
141
139
  resp = await asyncio.to_thread(
142
140
  requests.post,
143
- f"{BASE_URL}/images/generations",
141
+ f"{get_agnes_base_url()}/images/generations",
144
142
  headers=self.headers,
145
143
  json=payload,
146
144
  timeout=(30, 120),
@@ -213,7 +211,7 @@ class AgnesVideoAPI:
213
211
  start_time = asyncio.get_event_loop().time()
214
212
  curl_cmd = (
215
213
  f'curl -s -H "Authorization: Bearer $AGNES_API_KEY" '
216
- f'"{API_ROOT}/agnesapi?video_id={video_id}"'
214
+ f'"{get_agnes_api_root()}/agnesapi?video_id={video_id}"'
217
215
  )
218
216
  while True:
219
217
  # M2: 每次轮询前检查停止信号
@@ -244,7 +242,7 @@ class AgnesVideoAPI:
244
242
  resp = await asyncio.wait_for(
245
243
  asyncio.to_thread(
246
244
  requests.get,
247
- f"{API_ROOT}/agnesapi?video_id={video_id}",
245
+ f"{get_agnes_api_root()}/agnesapi?video_id={video_id}",
248
246
  headers=self.headers,
249
247
  timeout=15,
250
248
  ),
@@ -319,7 +317,7 @@ class AgnesVideoAPI:
319
317
  resp = await asyncio.wait_for(
320
318
  asyncio.to_thread(
321
319
  requests.post,
322
- f"{BASE_URL}/videos",
320
+ f"{get_agnes_base_url()}/videos",
323
321
  headers=self.headers,
324
322
  json=payload,
325
323
  timeout=(15, 60),
@@ -534,7 +534,7 @@ class VideoConcatenator:
534
534
  # Step 3: Build concat file list for ffmpeg
535
535
  loop_dir = os.path.dirname(output_path)
536
536
  concat_file = os.path.join(loop_dir, "_anchor_concat.txt")
537
- with open(concat_file, "w") as f:
537
+ with open(concat_file, "w", encoding="utf-8") as f:
538
538
  for _ in range(n):
539
539
  f.write(f"file '{clip_path}'\n")
540
540
 
package/core/config.py CHANGED
@@ -472,3 +472,50 @@ def set_selected_models(text: str = None, image: str = None, video: str = None)
472
472
  config["models"] = m
473
473
  save_config(config)
474
474
  return get_selected_models()
475
+
476
+
477
+ # ═══════════════════════════════════════════════════
478
+ # Agnes API 域名配置(v6.0)
479
+ # ═══════════════════════════════════════════════════
480
+
481
+ # 可用域名映射
482
+ AGNES_DOMAIN_MAP = {
483
+ "com": "https://apihub.agnes-ai.com",
484
+ "cn": "https://apihub.agnes-ai.cn",
485
+ }
486
+
487
+ _DEFAULT_DOMAIN = "com"
488
+
489
+
490
+ def get_agnes_domain() -> str:
491
+ """返回当前配置的域名后缀。
492
+
493
+ Returns:
494
+ "com" 或 "cn"
495
+ """
496
+ config = load_config()
497
+ return config.get("agnes_domain", _DEFAULT_DOMAIN)
498
+
499
+
500
+ def set_agnes_domain(domain: str):
501
+ """设置 Agnes API 域名后缀。
502
+
503
+ Args:
504
+ domain: "com" 或 "cn"
505
+ """
506
+ config = load_config()
507
+ config["agnes_domain"] = domain
508
+ save_config(config)
509
+
510
+
511
+ def get_agnes_base_url() -> str:
512
+ """返回基于当前域名配置的完整 API Base URL(含 /v1 后缀)。"""
513
+ domain = get_agnes_domain()
514
+ root = AGNES_DOMAIN_MAP.get(domain, AGNES_DOMAIN_MAP[_DEFAULT_DOMAIN])
515
+ return f"{root}/v1"
516
+
517
+
518
+ def get_agnes_api_root() -> str:
519
+ """返回基于当前域名配置的 API Root URL(不含 /v1 后缀)。"""
520
+ domain = get_agnes_domain()
521
+ return AGNES_DOMAIN_MAP.get(domain, AGNES_DOMAIN_MAP[_DEFAULT_DOMAIN])
@@ -393,10 +393,10 @@ class BasePipeline(ABC):
393
393
  """持久化任务元数据(video_id 等)到 sub_dir/task.json + curl.sh。"""
394
394
  os.makedirs(sub_dir, exist_ok=True)
395
395
  task_file = os.path.join(sub_dir, "task.json")
396
- with open(task_file, "w") as f:
396
+ with open(task_file, "w", encoding="utf-8") as f:
397
397
  json.dump(data, f, indent=2)
398
398
  curl_file = os.path.join(sub_dir, "curl.sh")
399
- with open(curl_file, "w") as f:
399
+ with open(curl_file, "w", encoding="utf-8") as f:
400
400
  f.write(self._make_curl(data.get("video_id", "")) + "\n")
401
401
 
402
402
  def _load_task_json(self, sub_dir: str) -> Optional[str]:
@@ -404,7 +404,7 @@ class BasePipeline(ABC):
404
404
  task_file = os.path.join(sub_dir, "task.json")
405
405
  if os.path.exists(task_file):
406
406
  try:
407
- with open(task_file, "r") as f:
407
+ with open(task_file, "r", encoding="utf-8") as f:
408
408
  data = json.load(f)
409
409
  return data.get("video_id") or data.get("task_id")
410
410
  except Exception as e:
@@ -233,7 +233,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
233
233
  if self._state.step_image_analysis == StepStatus.COMPLETED:
234
234
  analysis_file = self._state.image_analysis_file
235
235
  if os.path.exists(analysis_file):
236
- with open(analysis_file, "r") as f:
236
+ with open(analysis_file, "r", encoding="utf-8") as f:
237
237
  content = f.read()
238
238
  # 检测之前分析失败留下的错误文本,强制重新分析
239
239
  if "(分析失败" in content:
@@ -273,7 +273,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
273
273
  )
274
274
 
275
275
  analysis_file = os.path.join(self.working_dir, "image_analysis.txt")
276
- with open(analysis_file, "w") as f:
276
+ with open(analysis_file, "w", encoding="utf-8") as f:
277
277
  f.write(image_context)
278
278
 
279
279
  self._state.step_image_analysis = StepStatus.COMPLETED
@@ -411,7 +411,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
411
411
  story_path = self._state.story_file
412
412
  if os.path.exists(story_path):
413
413
  logger.info("[Pipeline] Step story: SKIP (already completed, file exists)")
414
- with open(story_path, "r") as f:
414
+ with open(story_path, "r", encoding="utf-8") as f:
415
415
  return f.read()
416
416
  logger.warning("[Pipeline] Step story: marked completed but file missing, re-running")
417
417
 
@@ -428,7 +428,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
428
428
  )
429
429
 
430
430
  story_path = os.path.join(self.working_dir, "story.txt")
431
- with open(story_path, "w") as f:
431
+ with open(story_path, "w", encoding="utf-8") as f:
432
432
  f.write(story)
433
433
 
434
434
  self._state.step_story = StepStatus.COMPLETED
@@ -481,7 +481,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
481
481
  if os.path.exists(ref_img_path) and os.path.exists(ref_prompt_path):
482
482
  self._state.step_character_ref = StepStatus.COMPLETED
483
483
  self._state.character_ref_file = ref_img_path
484
- with open(ref_prompt_path, "r") as f:
484
+ with open(ref_prompt_path, "r", encoding="utf-8") as f:
485
485
  self._state.character_ref_prompt = f.read()
486
486
  self.task_manager.update_state(
487
487
  step_character_ref=StepStatus.COMPLETED,
@@ -494,7 +494,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
494
494
  char_prompt = await asyncio.to_thread(
495
495
  self.screenwriter.extract_character_description, story, self._state.style
496
496
  )
497
- with open(ref_prompt_path, "w") as f:
497
+ with open(ref_prompt_path, "w", encoding="utf-8") as f:
498
498
  f.write(char_prompt)
499
499
 
500
500
  await self._emit("character_ref", "running", "正在生成角色参考图 (t2i)...", 0.12)
@@ -532,7 +532,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
532
532
  script_path = self._state.script_file
533
533
  if os.path.exists(script_path):
534
534
  logger.info("[Pipeline] Step script: SKIP (already completed, file exists)")
535
- with open(script_path, "r") as f:
535
+ with open(script_path, "r", encoding="utf-8") as f:
536
536
  scenes = json.load(f)
537
537
  if self._state.scene_count == len(scenes):
538
538
  return scenes
@@ -550,7 +550,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
550
550
  )
551
551
 
552
552
  script_path = os.path.join(self.working_dir, "script.json")
553
- with open(script_path, "w") as f:
553
+ with open(script_path, "w", encoding="utf-8") as f:
554
554
  json.dump(scenes, f, ensure_ascii=False, indent=2)
555
555
 
556
556
  self._state.scene_count = len(scenes)
@@ -620,7 +620,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
620
620
  prompts_path = self._state.end_frame_prompts_file
621
621
  if os.path.exists(prompts_path):
622
622
  logger.info("[Pipeline] Step end_frame_prompts: SKIP (already completed, file exists)")
623
- with open(prompts_path, "r") as f:
623
+ with open(prompts_path, "r", encoding="utf-8") as f:
624
624
  return json.load(f)
625
625
  logger.warning("[Pipeline] Step end_frame_prompts: marked completed but file missing, re-running")
626
626
 
@@ -638,7 +638,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
638
638
  )
639
639
 
640
640
  prompts_path = os.path.join(self.working_dir, "end_frame_prompts.json")
641
- with open(prompts_path, "w") as f:
641
+ with open(prompts_path, "w", encoding="utf-8") as f:
642
642
  json.dump(end_frame_prompts, f, ensure_ascii=False, indent=2)
643
643
 
644
644
  self._state.step_end_frame_prompts = StepStatus.COMPLETED
@@ -921,10 +921,10 @@ class CreativeVideoPipeline(MultiScenePipeline):
921
921
  video_id: Remote video task identifier.
922
922
  """
923
923
  task_file = os.path.join(scene_dir, "task.json")
924
- with open(task_file, "w") as f:
924
+ with open(task_file, "w", encoding="utf-8") as f:
925
925
  json.dump({"video_id": video_id}, f, indent=2)
926
926
  curl_file = os.path.join(scene_dir, "curl.sh")
927
- with open(curl_file, "w") as f:
927
+ with open(curl_file, "w", encoding="utf-8") as f:
928
928
  f.write(self._make_curl(video_id) + "\n")
929
929
 
930
930
  def _load_scene_task(self, scene_dir: str) -> Optional[str]:
@@ -939,7 +939,7 @@ class CreativeVideoPipeline(MultiScenePipeline):
939
939
  task_file = os.path.join(scene_dir, "task.json")
940
940
  if os.path.exists(task_file):
941
941
  try:
942
- with open(task_file, "r") as f:
942
+ with open(task_file, "r", encoding="utf-8") as f:
943
943
  data = json.load(f)
944
944
  return data.get("video_id") or data.get("task_id")
945
945
  except Exception as e:
@@ -77,7 +77,7 @@ class TaskManager:
77
77
  return None
78
78
 
79
79
  try:
80
- with open(self._task_file, "r") as f:
80
+ with open(self._task_file, "r", encoding="utf-8") as f:
81
81
  data = json.load(f)
82
82
 
83
83
  # v2.0:通过 parse_task_state 工厂函数反序列化
@@ -195,7 +195,7 @@ class TaskManager:
195
195
  task_file = os.path.join(working_dir, name, "task_state.json")
196
196
  if os.path.exists(task_file):
197
197
  try:
198
- with open(task_file, "r") as f:
198
+ with open(task_file, "r", encoding="utf-8") as f:
199
199
  data = json.load(f)
200
200
  tasks.append({
201
201
  "task_id": data.get("task_id", name),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-short-video",
3
- "version": "5.1.7",
3
+ "version": "5.2.0",
4
4
  "description": "Free AI short-video generator — run the full free-short-video service locally with one command. Text-to-video, image generation, Edge-TTS narration, subtitles, and compositing, all free.",
5
5
  "bin": {
6
6
  "free-short-video": "bin/cli.js",
package/server.py CHANGED
@@ -34,7 +34,7 @@ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
34
34
  from fastapi.staticfiles import StaticFiles
35
35
  from fastapi.responses import FileResponse, JSONResponse
36
36
 
37
- from core.config import get_api_key, set_api_key, delete_api_key, get_api_key_source, get_working_dir, DURATION_FRAME_MAP, get_workspaces, add_workspace, remove_workspace, set_active_workspace, get_active_workspace, REGRESSION_WORKING_DIR_ENV, get_watermark_config, set_watermark_config, WATERMARK_PROMO_TEXT_ZH, WATERMARK_PROMO_TEXT_EN, get_selected_models, set_selected_models
37
+ from core.config import get_api_key, set_api_key, delete_api_key, get_api_key_source, get_working_dir, DURATION_FRAME_MAP, get_workspaces, add_workspace, remove_workspace, set_active_workspace, get_active_workspace, REGRESSION_WORKING_DIR_ENV, get_watermark_config, set_watermark_config, WATERMARK_PROMO_TEXT_ZH, WATERMARK_PROMO_TEXT_EN, get_selected_models, set_selected_models, get_agnes_domain, set_agnes_domain, AGNES_DOMAIN_MAP
38
38
  from core.path_security import safe_join, safe_workspace_path, UnsafePathError
39
39
  from core.audio.voices import (
40
40
  get_voice_catalog,
@@ -369,6 +369,8 @@ async def get_config():
369
369
  "watermark_promo_zh": WATERMARK_PROMO_TEXT_ZH,
370
370
  "watermark_promo_en": WATERMARK_PROMO_TEXT_EN,
371
371
  "models": get_selected_models(),
372
+ "agnes_domain": get_agnes_domain(),
373
+ "agnes_domains": list(AGNES_DOMAIN_MAP.keys()),
372
374
  }
373
375
  return data
374
376
 
@@ -461,6 +463,28 @@ async def save_watermark_config(enabled: bool = Form(False)):
461
463
  return {"ok": True, "enabled": enabled}
462
464
 
463
465
 
466
+ # ═══════════════════════════════════════════════════
467
+ # 域名配置(v6.0)
468
+ # ═══════════════════════════════════════════════════
469
+
470
+
471
+ @app.post("/api/config/domain")
472
+ async def save_agnes_domain(domain: str = Form(...)):
473
+ """设置 Agnes API 域名后缀。
474
+
475
+ Args:
476
+ domain: "com" 或 "cn"
477
+ """
478
+ domain = domain.strip().lower()
479
+ if domain not in AGNES_DOMAIN_MAP:
480
+ raise HTTPException(
481
+ status_code=422,
482
+ detail=f"域名后缀必须为 {list(AGNES_DOMAIN_MAP.keys())} 之一",
483
+ )
484
+ set_agnes_domain(domain)
485
+ return {"ok": True, "agnes_domain": domain}
486
+
487
+
464
488
  # ═══════════════════════════════════════════════════
465
489
  # 工作目录管理(多工作目录,同时仅一个 active)
466
490
  # ═══════════════════════════════════════════════════
package/static/index.html CHANGED
@@ -405,6 +405,50 @@
405
405
  </div>
406
406
  </div>
407
407
 
408
+ <!-- 域名配置(v6.0) -->
409
+ <div id="domain-section" class="glass-card rounded-2xl mb-6 overflow-hidden transition-all duration-300">
410
+ <!-- Compact bar (shown when collapsed) -->
411
+ <div id="domain-compact" class="hidden flex items-center justify-between px-6 py-3 cursor-pointer hover:bg-white/[0.02] transition" onclick="toggleConfigPanel('domain')" role="button" tabindex="0" aria-expanded="false" aria-label="展开域名配置" onkeydown="if(event.key==='Enter')toggleConfigPanel('domain')">
412
+ <div class="flex items-center gap-3">
413
+ <span class="text-sm">🌐</span>
414
+ <span id="domain-compact-text" class="text-sm text-gray-400"></span>
415
+ </div>
416
+ <span id="domain-arrow" class="text-gray-600 text-xs transition-transform duration-300">▶</span>
417
+ </div>
418
+ <!-- Expanded body -->
419
+ <div id="domain-body" class="p-6 pt-4">
420
+ <div class="flex items-center justify-between mb-3">
421
+ <h2 class="text-lg font-semibold text-blue-300" data-i18n="domainTitle">Agnes 域名配置</h2>
422
+ <div class="flex items-center gap-2">
423
+ <span id="domain-status" class="text-xs px-2 py-1 rounded-full bg-gray-800 text-gray-400">apihub.agnes-ai.com</span>
424
+ <button onclick="toggleConfigPanel('domain')" class="text-xs text-gray-500 hover:text-gray-300 transition px-2 py-1 rounded" title="收起" aria-label="收起域名配置">▲</button>
425
+ </div>
426
+ </div>
427
+ <p class="text-xs text-gray-500 mb-4" data-i18n="domainHint">选择 Agnes API 的接入域名。在中国大陆推荐使用 .cn 域名,以获得更稳定流畅的访问体验。</p>
428
+ <div class="space-y-3">
429
+ <label class="flex items-center gap-3 glass-input rounded-lg px-4 py-3 cursor-pointer hover:border-blue-500/40 transition">
430
+ <input type="radio" name="agnes-domain" value="com" class="accent-blue-500 w-4 h-4 cursor-pointer">
431
+ <div>
432
+ <span class="text-sm text-gray-200 font-medium">apihub.agnes-ai.com</span>
433
+ <span class="text-xs text-gray-500 ml-2" data-i18n="domainComLabel">国际域名</span>
434
+ </div>
435
+ </label>
436
+ <label class="flex items-center gap-3 glass-input rounded-lg px-4 py-3 cursor-pointer hover:border-blue-500/40 transition">
437
+ <input type="radio" name="agnes-domain" value="cn" class="accent-blue-500 w-4 h-4 cursor-pointer">
438
+ <div>
439
+ <span class="text-sm text-gray-200 font-medium">apihub.agnes-ai.cn</span>
440
+ <span class="text-xs text-gray-500 ml-2" data-i18n="domainCnLabel">中国大陆推荐</span>
441
+ </div>
442
+ </label>
443
+ </div>
444
+ <div class="flex gap-3 mt-4">
445
+ <button id="domain-save" class="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm font-medium transition shadow-lg shadow-blue-600/20" data-i18n="save">保存</button>
446
+ <span id="domain-save-status" class="self-center text-xs text-green-400 hidden" data-i18n="domainSaved">已保存</span>
447
+ <span id="domain-save-error" class="self-center text-xs text-red-400 hidden"></span>
448
+ </div>
449
+ </div>
450
+ </div>
451
+
408
452
  <!-- 工作目录 -->
409
453
  <div id="workspace-section" class="glass-card rounded-2xl mb-6 overflow-hidden transition-all duration-300">
410
454
  <!-- Compact bar (shown when collapsed) -->
@@ -1603,6 +1647,12 @@ const T = {
1603
1647
  connLostMsg: '与服务端的实时连接已断开。服务可能仍在后台运行。可到任务列表查看状态并续传。',
1604
1648
  gotoList: '前往任务列表', reconnecting: '实时连接断开,正在重试...',
1605
1649
  ttAnchor: '数字人口播',
1650
+ domainTitle: 'Agnes 域名配置',
1651
+ domainHint: '选择 Agnes API 的接入域名。在中国大陆推荐使用 .cn 域名,以获得更稳定流畅的访问体验。',
1652
+ domainComLabel: '国际域名',
1653
+ domainCnLabel: '中国大陆推荐',
1654
+ domainSaved: '已保存',
1655
+ domainSaveFailed: '保存失败',
1606
1656
  ttPoetry: '诗词视频', poetrySettings: '诗词设置',
1607
1657
  poemLabel: '古诗原文', poemPlaceholder: '输入完整古诗原文,例如:春眠不觉晓,处处闻啼鸟...',
1608
1658
  poemHint: '可多段;诗句间用换行或标点自然分隔。AI 会按诗意拆分为若干场景。',
@@ -1743,6 +1793,12 @@ const T = {
1743
1793
  connLostMsg: 'Connection lost. Server may still be running. Check task list for status.',
1744
1794
  gotoList: 'Go to task list', reconnecting: 'Connection lost, retrying...',
1745
1795
  ttAnchor: 'Digital Human',
1796
+ domainTitle: 'Agnes Domain',
1797
+ domainHint: 'Choose the Agnes API access domain. In China, .cn is recommended for better connectivity.',
1798
+ domainComLabel: 'Global',
1799
+ domainCnLabel: 'China (recommended)',
1800
+ domainSaved: 'Saved',
1801
+ domainSaveFailed: 'Save failed',
1746
1802
  ttPoetry: 'Poetry Video', poetrySettings: 'Poetry Settings',
1747
1803
  poemLabel: 'Poem Text', poemPlaceholder: 'Enter the full poem, e.g. 春眠不觉晓,处处闻啼鸟...', poemHint: 'Multiple stanzas allowed; separated by line breaks or punctuation. AI splits into scenes by meaning.',
1748
1804
  scenePromptsOptional: 'Scene Prompts (Optional)', scenePromptsPlaceholder: 'Format: verse | visual description (one scene per line)', scenePromptsHint: 'Optional. One scene per line, fixed format "verse | visual description". Two ways give identical results: ① leave empty and let the system generate; ② copy the prompt below to any AI, then paste the result back.', scenePromptsExample: 'e.g. Spring sleeps, unaware of dawn... | Misty spring morning, birds singing',
@@ -1872,8 +1928,14 @@ const T = {
1872
1928
  resumed: 'Задача возобновлена', watchLog: 'Следите за логами.',
1873
1929
  task_: 'Задача', genFailed: 'Ошибка', genFailedMsg: 'Проверьте логи.',
1874
1930
  gotoResume: 'К списку задач', connLost: 'Соединение потеряно',
1875
- connLostMsg: 'Соединение потеряно.', gotoList: 'К списку', reconnecting: 'Переподключение...',
1931
+ connLostMsg: 'Соединение потеряно.', gotoList: 'К списку', reconnecting: 'Переподключение...',
1876
1932
  ttAnchor: 'Цифровой человек',
1933
+ domainTitle: 'Домен Agnes',
1934
+ domainHint: 'Выберите домен для API Agnes. В Китае рекомендуется .cn для стабильного доступа.',
1935
+ domainComLabel: 'Глобальный',
1936
+ domainCnLabel: 'Китай (рекомендуется)',
1937
+ domainSaved: 'Сохранено',
1938
+ domainSaveFailed: 'Ошибка сохранения',
1877
1939
  ttImage: 'Простое изображение', imageSettings: 'Настройки изображения',
1878
1940
  imagePrompt: 'Описание', imagePromptPlaceholder: 'Опишите желаемое изображение...',
1879
1941
  imageSize: 'Размер', refImageHint: 'Опционально. Загрузите для создания вариаций.',
@@ -1981,6 +2043,12 @@ const T = {
1981
2043
  task_: 'タスク', genFailed: '生成失敗', genFailedMsg: 'ログを確認。',
1982
2044
  gotoResume: 'タスク一覧へ', connLost: '接続切断', connLostMsg: '接続が切れました。', gotoList: '一覧へ', reconnecting: '再接続中...',
1983
2045
  ttAnchor: 'デジタルヒューマン',
2046
+ domainTitle: 'Agnes ドメイン',
2047
+ domainHint: 'Agnes API のドメインを選択。中国では .cn が推奨されます。',
2048
+ domainComLabel: 'グローバル',
2049
+ domainCnLabel: '中国 (推奨)',
2050
+ domainSaved: '保存しました',
2051
+ domainSaveFailed: '保存に失敗しました',
1984
2052
  ttImage: '簡単画像', imageSettings: '画像設定',
1985
2053
  imagePrompt: '画像説明', imagePromptPlaceholder: '希望する画像を説明してください...',
1986
2054
  imageSize: '画像サイズ', refImageHint: '任意。アップロードしてバリエーションを生成。',
@@ -2088,6 +2156,12 @@ const T = {
2088
2156
  task_: '작업', genFailed: '생성 실패', genFailedMsg: '로그를 확인하세요.',
2089
2157
  gotoResume: '작업 목록으로', connLost: '연결 끊김', connLostMsg: '연결이 끊어졌습니다.', gotoList: '목록으로', reconnecting: '재연결 중...',
2090
2158
  ttAnchor: '디지털 휴먼',
2159
+ domainTitle: 'Agnes 도메인',
2160
+ domainHint: 'Agnes API 도메인을 선택하세요. 중국에서는 .cn 사용을 권장합니다.',
2161
+ domainComLabel: '글로벌',
2162
+ domainCnLabel: '중국 (권장)',
2163
+ domainSaved: '저장됨',
2164
+ domainSaveFailed: '저장 실패',
2091
2165
  ttImage: '간단 이미지', imageSettings: '이미지 설정',
2092
2166
  imagePrompt: '이미지 설명', imagePromptPlaceholder: '원하는 이미지를 설명하세요...',
2093
2167
  imageSize: '이미지 크기', refImageHint: '선택사항. 업로드하여 변형을 생성합니다.',
@@ -2195,6 +2269,12 @@ const T = {
2195
2269
  task_: 'Tugas', genFailed: 'Gagal', genFailedMsg: 'Periksa log.',
2196
2270
  gotoResume: 'Ke senarai', connLost: 'Sambungan terputus', connLostMsg: 'Sambungan terputus.', gotoList: 'Ke senarai', reconnecting: 'Menyambung semula...',
2197
2271
  ttAnchor: 'Digital Human',
2272
+ domainTitle: 'Domain Agnes',
2273
+ domainHint: 'Pilih domain API Agnes. Di China, .cn disyorkan untuk akses yang lebih stabil.',
2274
+ domainComLabel: 'Global',
2275
+ domainCnLabel: 'China (disyorkan)',
2276
+ domainSaved: 'Disimpan',
2277
+ domainSaveFailed: 'Gagal simpan',
2198
2278
  ttImage: 'Imej Ringkas', imageSettings: 'Tetapan Imej',
2199
2279
  imagePrompt: 'Penerangan Imej', imagePromptPlaceholder: 'Huraikan imej yang anda mahukan...',
2200
2280
  imageSize: 'Saiz Imej', refImageHint: 'Pilihan. Muat naik untuk hasilkan variasi.',
@@ -2301,6 +2381,12 @@ const T = {
2301
2381
  task_: 'Tugas', genFailed: 'Gagal', genFailedMsg: 'Periksa log.',
2302
2382
  gotoResume: 'Ke daftar', connLost: 'Koneksi terputus', connLostMsg: 'Koneksi terputus.', gotoList: 'Ke daftar', reconnecting: 'Menyambung ulang...',
2303
2383
  ttAnchor: 'Digital Human',
2384
+ domainTitle: 'Domain Agnes',
2385
+ domainHint: 'Pilih domain API Agnes. Di China, .cn direkomendasikan untuk akses lebih stabil.',
2386
+ domainComLabel: 'Global',
2387
+ domainCnLabel: 'China (direkomendasikan)',
2388
+ domainSaved: 'Disimpan',
2389
+ domainSaveFailed: 'Gagal simpan',
2304
2390
  ttImage: 'Gambar Sederhana', imageSettings: 'Pengaturan Gambar',
2305
2391
  imagePrompt: 'Deskripsi Gambar', imagePromptPlaceholder: 'Jelaskan gambar yang Anda inginkan...',
2306
2392
  imageSize: 'Ukuran Gambar', refImageHint: 'Opsional. Unggah untuk menghasilkan variasi.',
@@ -2413,6 +2499,12 @@ const T = {
2413
2499
  connLostMsg: 'Verbindung zum Server verloren. Server läuft möglicherweise noch. Aufgabenliste prüfen.',
2414
2500
  gotoList: 'Zur Aufgabenliste', reconnecting: 'Verbindung verloren, neuer Versuch...',
2415
2501
  ttAnchor: 'Digitaler Mensch',
2502
+ domainTitle: 'Agnes-Domain',
2503
+ domainHint: 'Wählen Sie die API-Domain für Agnes. In China wird .cn für bessere Konnektivität empfohlen.',
2504
+ domainComLabel: 'Global',
2505
+ domainCnLabel: 'China (empfohlen)',
2506
+ domainSaved: 'Gespeichert',
2507
+ domainSaveFailed: 'Speichern fehlgeschlagen',
2416
2508
  ttImage: 'Einfaches Bild', imageSettings: 'Bildeinstellungen',
2417
2509
  imagePrompt: 'Bildbeschreibung', imagePromptPlaceholder: 'Gewünschtes Bild beschreiben...',
2418
2510
  imageSize: 'Bildgröße', refImageHint: 'Optional. Hochladen für Varianten; leer lassen für reine KI-Generierung.',
@@ -2525,6 +2617,12 @@ const T = {
2525
2617
  connLostMsg: 'Connexion au serveur perdue. Le serveur fonctionne peut-être encore. Vérifiez la liste des tâches.',
2526
2618
  gotoList: 'Aller à la liste', reconnecting: 'Connexion perdue, nouvelle tentative...',
2527
2619
  ttAnchor: 'Humain numérique',
2620
+ domainTitle: 'Domaine Agnes',
2621
+ domainHint: 'Choisissez le domaine API Agnes. En Chine, .cn est recommandé pour une meilleure connectivité.',
2622
+ domainComLabel: 'Mondial',
2623
+ domainCnLabel: 'Chine (recommandé)',
2624
+ domainSaved: 'Enregistré',
2625
+ domainSaveFailed: 'Échec de l\'enregistrement',
2528
2626
  ttImage: 'Image simple', imageSettings: 'Paramètres image',
2529
2627
  imagePrompt: 'Description image', imagePromptPlaceholder: 'Décrivez l\'image souhaitée...',
2530
2628
  imageSize: 'Taille image', refImageHint: 'Optionnel. Télécharger pour générer des variations ; laisser vide pour génération IA pure.',
@@ -2637,6 +2735,12 @@ const T = {
2637
2735
  connLostMsg: 'Verbinding met server verbroken. Server draait mogelijk nog. Controleer de takenlijst.',
2638
2736
  gotoList: 'Naar takenlijst', reconnecting: 'Verbinding verbroken, opnieuw proberen...',
2639
2737
  ttAnchor: 'Digitale mens',
2738
+ domainTitle: 'Agnes-domein',
2739
+ domainHint: 'Kies het API-domein voor Agnes. In China wordt .cn aanbevolen voor een betere connectiviteit.',
2740
+ domainComLabel: 'Globaal',
2741
+ domainCnLabel: 'China (aanbevolen)',
2742
+ domainSaved: 'Opgeslagen',
2743
+ domainSaveFailed: 'Opslaan mislukt',
2640
2744
  ttImage: 'Eenvoudige afbeelding', imageSettings: 'Afbeeldingsinstellingen',
2641
2745
  imagePrompt: 'Afbeeldingsbeschrijving', imagePromptPlaceholder: 'Beschrijf de gewenste afbeelding...',
2642
2746
  imageSize: 'Afbeeldingsgrootte', refImageHint: 'Optioneel. Uploaden voor variaties; leeg laten voor pure AI-generatie.',
@@ -2749,6 +2853,12 @@ const T = {
2749
2853
  connLostMsg: 'Conexión con el servidor perdida. El servidor puede seguir funcionando. Revise la lista de tareas.',
2750
2854
  gotoList: 'Ir a la lista', reconnecting: 'Conexión perdida, reintentando...',
2751
2855
  ttAnchor: 'Humano digital',
2856
+ domainTitle: 'Dominio de Agnes',
2857
+ domainHint: 'Elija el dominio de API de Agnes. En China, se recomienda .cn para una mejor conectividad.',
2858
+ domainComLabel: 'Global',
2859
+ domainCnLabel: 'China (recomendado)',
2860
+ domainSaved: 'Guardado',
2861
+ domainSaveFailed: 'Error al guardar',
2752
2862
  ttImage: 'Imagen simple', imageSettings: 'Configuración de imagen',
2753
2863
  imagePrompt: 'Descripción de imagen', imagePromptPlaceholder: 'Describa la imagen deseada...',
2754
2864
  imageSize: 'Tamaño de imagen', refImageHint: 'Opcional. Subir para generar variaciones; dejar vacío para generación IA pura.',
@@ -2861,6 +2971,12 @@ const T = {
2861
2971
  connLostMsg: 'Conexão com o servidor perdida. O servidor pode ainda estar funcionando. Verifique a lista de tarefas.',
2862
2972
  gotoList: 'Ir para a lista', reconnecting: 'Conexão perdida, tentando novamente...',
2863
2973
  ttAnchor: 'Humano digital',
2974
+ domainTitle: 'Domínio Agnes',
2975
+ domainHint: 'Escolha o domínio da API Agnes. Na China, recomenda-se .cn para melhor conectividade.',
2976
+ domainComLabel: 'Global',
2977
+ domainCnLabel: 'China (recomendado)',
2978
+ domainSaved: 'Salvo',
2979
+ domainSaveFailed: 'Falha ao salvar',
2864
2980
  ttImage: 'Imagem simples', imageSettings: 'Configurações de imagem',
2865
2981
  imagePrompt: 'Descrição da imagem', imagePromptPlaceholder: 'Descreva a imagem desejada...',
2866
2982
  imageSize: 'Tamanho da imagem', refImageHint: 'Opcional. Enviar para gerar variações; deixar vazio para geração IA pura.',
@@ -2973,6 +3089,12 @@ const T = {
2973
3089
  connLostMsg: 'Connessione al server persa. Il server potrebbe essere ancora in esecuzione. Controlla l\'elenco dei task.',
2974
3090
  gotoList: 'Vai all\'elenco', reconnecting: 'Connessione persa, nuovo tentativo...',
2975
3091
  ttAnchor: 'Umano digitale',
3092
+ domainTitle: 'Dominio Agnes',
3093
+ domainHint: 'Scegli il dominio API per Agnes. In Cina, .cn è consigliato per una migliore connettività.',
3094
+ domainComLabel: 'Globale',
3095
+ domainCnLabel: 'Cina (consigliato)',
3096
+ domainSaved: 'Salvato',
3097
+ domainSaveFailed: 'Salvataggio fallito',
2976
3098
  ttImage: 'Immagine semplice', imageSettings: 'Impostazioni immagine',
2977
3099
  imagePrompt: 'Descrizione immagine', imagePromptPlaceholder: 'Descrivi l\'immagine desiderata...',
2978
3100
  imageSize: 'Dimensione immagine', refImageHint: 'Opzionale. Carica per generare varianti; lascia vuoto per generazione IA pura.',
@@ -3067,6 +3189,9 @@ function applyLanguage(lang) {
3067
3189
  image: (document.getElementById('model-image') || {}).value || '',
3068
3190
  video: (document.getElementById('model-video') || {}).value || ''
3069
3191
  });
3192
+ // 刷新域名紧凑栏(含 i18n 标题)
3193
+ const domainRadio = document.querySelector('input[name="agnes-domain"]:checked');
3194
+ if (domainRadio) updateDomainCompact(domainRadio.value);
3070
3195
  }
3071
3196
 
3072
3197
  function switchLang(lang) { applyLanguage(lang); }
@@ -3149,6 +3274,15 @@ const STEPS = {
3149
3274
  }
3150
3275
  // 加载模型选择(v5.0)
3151
3276
  try { await loadModels(); } catch(e) { console.error('init models error:', e); }
3277
+ // 加载域名配置(v6.0)
3278
+ if (d.agnes_domain) {
3279
+ const radios = document.querySelectorAll('input[name="agnes-domain"]');
3280
+ for (const r of radios) {
3281
+ if (r.value === d.agnes_domain) r.checked = true;
3282
+ }
3283
+ updateDomainCompact(d.agnes_domain);
3284
+ updateDomainStatus(d.agnes_domain);
3285
+ }
3152
3286
  } catch(e) { console.error('init config load error:', e); }
3153
3287
 
3154
3288
  // Character count for manuscript
@@ -5062,6 +5196,60 @@ document.getElementById('model-save').onclick = async () => {
5062
5196
  }
5063
5197
  };
5064
5198
 
5199
+ // ═══════════════════════════════════════════════════
5200
+ // 域名配置(v6.0)
5201
+ // ═══════════════════════════════════════════════════
5202
+
5203
+ function updateDomainCompact(domain) {
5204
+ const compact = document.getElementById('domain-compact');
5205
+ const textEl = document.getElementById('domain-compact-text');
5206
+ const isCn = domain === 'cn';
5207
+ textEl.innerHTML =
5208
+ '<span class="text-gray-300 font-medium">' + t('domainTitle') + '</span>' +
5209
+ '<span class="text-gray-600 mx-2">·</span>' +
5210
+ '<span class="' + (isCn ? 'text-green-400' : 'text-gray-400') + '">' +
5211
+ 'apihub.agnes-ai.' + escapeHtml(domain) +
5212
+ '</span>';
5213
+ }
5214
+
5215
+ function updateDomainStatus(domain) {
5216
+ const statusEl = document.getElementById('domain-status');
5217
+ statusEl.textContent = 'apihub.agnes-ai.' + domain;
5218
+ if (domain === 'cn') {
5219
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-green-900/40 text-green-300';
5220
+ } else {
5221
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-gray-800 text-gray-400';
5222
+ }
5223
+ }
5224
+
5225
+ document.getElementById('domain-save').onclick = async () => {
5226
+ const selected = document.querySelector('input[name="agnes-domain"]:checked');
5227
+ if (!selected) return;
5228
+ const domain = selected.value;
5229
+ const errEl = document.getElementById('domain-save-error');
5230
+ const okEl = document.getElementById('domain-save-status');
5231
+ errEl.classList.add('hidden');
5232
+ okEl.classList.add('hidden');
5233
+ const form = new FormData();
5234
+ form.append('domain', domain);
5235
+ try {
5236
+ const r = await fetch('/api/config/domain', { method: 'POST', body: form });
5237
+ if (r.ok) {
5238
+ okEl.classList.remove('hidden');
5239
+ updateDomainCompact(domain);
5240
+ updateDomainStatus(domain);
5241
+ setTimeout(() => okEl.classList.add('hidden'), 2000);
5242
+ } else {
5243
+ const d = await r.json().catch(() => ({}));
5244
+ errEl.textContent = d.detail || (t('domainSaveFailed') || '保存失败');
5245
+ errEl.classList.remove('hidden');
5246
+ }
5247
+ } catch (e) {
5248
+ errEl.textContent = '网络错误';
5249
+ errEl.classList.remove('hidden');
5250
+ }
5251
+ };
5252
+
5065
5253
  // ═══════════════════════════════════════════════════
5066
5254
  // 水印开关
5067
5255
  // ═══════════════════════════════════════════════════