free-short-video 5.2.1 → 5.3.1
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/bin/cli.js +28 -1
- package/core/api/agnes_image.py +4 -1
- package/core/api/agnes_video.py +4 -1
- package/core/artifacts.py +83 -0
- package/core/audio/subtitle/__init__.py +35 -0
- package/core/audio/subtitle/generator.py +899 -0
- package/core/audio/subtitle/renderer.py +212 -0
- package/core/audio/subtitle.py +16 -1103
- package/core/compositor/concatenator/__init__.py +35 -0
- package/core/compositor/concatenator/audio_overlay.py +376 -0
- package/core/compositor/concatenator/concat.py +293 -0
- package/core/compositor/concatenator.py +16 -653
- package/core/config.py +77 -16
- package/core/pipelines/__init__.py +109 -1
- package/core/pipelines/anchor_video.py +40 -59
- package/core/pipelines/creative/__init__.py +18 -0
- package/core/pipelines/creative/pipeline.py +173 -0
- package/core/pipelines/creative/steps_audio.py +477 -0
- package/core/pipelines/creative/steps_frames.py +371 -0
- package/core/pipelines/creative/steps_script.py +472 -0
- package/core/pipelines/creative/steps_video.py +546 -0
- package/core/pipelines/creative_video.py +23 -1968
- package/core/pipelines/manuscript_video.py +34 -51
- package/core/pipelines/multi_scene.py +68 -61
- package/core/pipelines/poetry_video.py +34 -49
- package/core/pipelines/simple_video.py +17 -9
- package/core/screenwriter/__init__.py +220 -0
- package/core/screenwriter/characters.py +550 -0
- package/core/screenwriter/scenes.py +338 -0
- package/core/screenwriter/story.py +550 -0
- package/core/screenwriter/style.py +276 -0
- package/models/task.py +6 -9
- package/package.json +2 -2
- package/server.py +78 -1964
- package/static/index.html +275 -78
- package/web/__init__.py +10 -0
- package/web/app_state.py +156 -0
- package/web/deps.py +185 -0
- package/web/helpers.py +284 -0
- package/web/routes/__init__.py +4 -0
- package/web/routes/config_routes.py +147 -0
- package/web/routes/image_routes.py +124 -0
- package/web/routes/task_creation_routes.py +620 -0
- package/web/routes/task_routes.py +184 -0
- package/web/routes/utility_routes.py +121 -0
- package/web/routes/video_routes.py +194 -0
- package/web/routes/voice_routes.py +80 -0
- package/web/routes/workspace_routes.py +86 -0
- package/core/screenwriter.py +0 -1860
package/bin/cli.js
CHANGED
|
@@ -21,6 +21,27 @@ const os = require('os');
|
|
|
21
21
|
|
|
22
22
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
23
23
|
|
|
24
|
+
// 终端超链接(ANSI OSC 8):TTY 下输出可点击链接,非 TTY(管道/重定向)降级为纯文本
|
|
25
|
+
const link = (url, text) =>
|
|
26
|
+
process.stdout.isTTY ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
|
|
27
|
+
|
|
28
|
+
// 启动图案(ASCII art):TTY 下亮青色显示,非 TTY 纯文本
|
|
29
|
+
const FSV_LOGO = String.raw` _____ ______ __
|
|
30
|
+
| ___/ ___\ \ / /
|
|
31
|
+
| |_ \___ \\ \ / /
|
|
32
|
+
| _| ___) |\ V /
|
|
33
|
+
|_| |____/ \_/`.split('\n');
|
|
34
|
+
function printLogo() {
|
|
35
|
+
if (process.stdout.isTTY) {
|
|
36
|
+
console.log('\x1b[1;36m');
|
|
37
|
+
FSV_LOGO.forEach((l) => console.log(l));
|
|
38
|
+
process.stdout.write('\x1b[0m');
|
|
39
|
+
} else {
|
|
40
|
+
console.log('');
|
|
41
|
+
FSV_LOGO.forEach((l) => console.log(l));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
function printHelp() {
|
|
25
46
|
console.log(`free-short-video — free AI short-video generator
|
|
26
47
|
|
|
@@ -37,6 +58,9 @@ Options:
|
|
|
37
58
|
Environment:
|
|
38
59
|
AGNES_API_KEY Your Agnes API key (can also be set later in the Web UI)
|
|
39
60
|
|
|
61
|
+
Website:
|
|
62
|
+
${link('https://video.lichuanyang.top', 'https://video.lichuanyang.top')} (official site & no-install online demo)
|
|
63
|
+
|
|
40
64
|
First run creates a local venv and installs Python dependencies automatically.
|
|
41
65
|
`);
|
|
42
66
|
}
|
|
@@ -96,7 +120,10 @@ function venvBin(name) {
|
|
|
96
120
|
|
|
97
121
|
// ── 1. python check ───────────────────────────────────────────────────────
|
|
98
122
|
console.log('================================================');
|
|
99
|
-
|
|
123
|
+
printLogo();
|
|
124
|
+
console.log(' free-short-video — Free AI short-video generator');
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(` 🌐 官网: ${link('https://video.lichuanyang.top', 'https://video.lichuanyang.top')} | ⚡ 在线体验: ${link('https://video.lichuanyang.top/demo', 'https://video.lichuanyang.top/demo')}`);
|
|
100
127
|
console.log('================================================');
|
|
101
128
|
console.log('');
|
|
102
129
|
|
package/core/api/agnes_image.py
CHANGED
|
@@ -17,6 +17,9 @@ from utils.image import download_image
|
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
|
+
# 图像生成读超时基数(秒):timeout = 基数 * (attempt + 1),首次 120s 逐步放宽
|
|
21
|
+
_READ_TIMEOUT_BASE_SECONDS = 120
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
class ImageOutput:
|
|
22
25
|
def __init__(self, fmt: str, ext: str, data: str):
|
|
@@ -115,7 +118,7 @@ class AgnesImageAPI:
|
|
|
115
118
|
# 全局限速:在发起 HTTP 请求前获取令牌
|
|
116
119
|
await asyncio.to_thread(get_rate_limiter().acquire)
|
|
117
120
|
# 动态超时:第一次 120s,后续逐步增加(图像生成较慢,放宽读超时)
|
|
118
|
-
read_timeout =
|
|
121
|
+
read_timeout = _READ_TIMEOUT_BASE_SECONDS * (attempt + 1)
|
|
119
122
|
resp = await asyncio.to_thread(
|
|
120
123
|
requests.post,
|
|
121
124
|
f"{get_agnes_base_url()}/images/generations",
|
package/core/api/agnes_video.py
CHANGED
|
@@ -26,6 +26,9 @@ DURATION_PRESETS = {
|
|
|
26
26
|
20: (409, 24), # capped at 409 (API max for 720p); actual ~17s
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
# 图片上传 429 退避间隔基数(秒):delay = 基数 * (attempt + 1)
|
|
30
|
+
_UPLOAD_RETRY_BASE_DELAY_SECONDS = 30
|
|
31
|
+
|
|
29
32
|
|
|
30
33
|
class VideoOutput:
|
|
31
34
|
def __init__(self, fmt: str, ext: str, data: str):
|
|
@@ -144,7 +147,7 @@ class AgnesVideoAPI:
|
|
|
144
147
|
timeout=(30, 120),
|
|
145
148
|
)
|
|
146
149
|
if resp.status_code == 429:
|
|
147
|
-
delay =
|
|
150
|
+
delay = _UPLOAD_RETRY_BASE_DELAY_SECONDS * (attempt + 1)
|
|
148
151
|
logger.warning(f"[AgnesVideo] Image upload 429, retry in {delay}s...")
|
|
149
152
|
await asyncio.sleep(delay)
|
|
150
153
|
continue
|
package/core/artifacts.py
CHANGED
|
@@ -12,7 +12,11 @@ core/artifacts.py — 中间产物注册表与级联删除计划
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
15
17
|
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import time
|
|
16
20
|
from dataclasses import dataclass, field
|
|
17
21
|
from typing import Any, Optional
|
|
18
22
|
|
|
@@ -572,3 +576,82 @@ def apply_cascade_plan(state: BaseTaskState, plan: CascadePlan) -> dict:
|
|
|
572
576
|
update_kwargs["status"] = StepStatus.PENDING
|
|
573
577
|
|
|
574
578
|
return update_kwargs
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ═══════════════════════════════════════════════════════════════
|
|
582
|
+
# 僵尸任务磁盘清理(v5.0 Batch 5 / 5.1)
|
|
583
|
+
# ═══════════════════════════════════════════════════════════════
|
|
584
|
+
|
|
585
|
+
_DEFAULT_PROTECT_STATUSES = {StepStatus.RUNNING, StepStatus.QUEUED, StepStatus.PENDING}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def sweep_stale_tasks(age_days: int = 7,
|
|
589
|
+
protect_statuses: Optional[set] = None) -> dict:
|
|
590
|
+
"""扫描并清理僵尸任务目录(状态文件超龄且任务非活跃)。
|
|
591
|
+
|
|
592
|
+
清理条件(全部满足才删除):
|
|
593
|
+
1. 工作区中存在 ``task_state.json`` 且可解析出 ``status`` 字段;
|
|
594
|
+
2. ``status`` 不在保护集合中;
|
|
595
|
+
3. ``task_state.json`` 的修改时间距今超过 ``age_days`` 天。
|
|
596
|
+
|
|
597
|
+
默认保护 ``RUNNING``(运行中)/ ``QUEUED``(排队中)/ ``PENDING``(断点续传
|
|
598
|
+
候选),保证活跃任务与可恢复任务永不误删;调用方可传 ``protect_statuses``
|
|
599
|
+
显式覆盖(如仅保护活跃状态,放开 PENDING)。
|
|
600
|
+
|
|
601
|
+
删除策略:整目录 ``shutil.rmtree``(等价于按级联计划删除全部产物 + 状态文件),
|
|
602
|
+
删除前校验 realpath 必须位于工作区内,防符号链接逃逸。
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
age_days: 状态文件超龄阈值(天)
|
|
606
|
+
protect_statuses: 保护的状态集合;None 时使用默认保护集合
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
{"swept": [目录名], "protected": [目录名], "errors": [描述]}
|
|
610
|
+
"""
|
|
611
|
+
working_dir = get_working_dir()
|
|
612
|
+
protect = protect_statuses if protect_statuses is not None else _DEFAULT_PROTECT_STATUSES
|
|
613
|
+
swept: list[str] = []
|
|
614
|
+
protected: list[str] = []
|
|
615
|
+
errors: list[str] = []
|
|
616
|
+
|
|
617
|
+
if not os.path.isdir(working_dir):
|
|
618
|
+
return {"swept": swept, "protected": protected, "errors": errors}
|
|
619
|
+
|
|
620
|
+
real_working = os.path.realpath(working_dir)
|
|
621
|
+
cutoff = time.time() - age_days * 86400
|
|
622
|
+
|
|
623
|
+
for name in sorted(os.listdir(working_dir)):
|
|
624
|
+
task_dir = os.path.join(working_dir, name)
|
|
625
|
+
if not os.path.isdir(task_dir):
|
|
626
|
+
continue
|
|
627
|
+
task_file = os.path.join(task_dir, "task_state.json")
|
|
628
|
+
if not os.path.isfile(task_file):
|
|
629
|
+
continue # 非任务目录(如 uploads/ 或普通文件目录)
|
|
630
|
+
try:
|
|
631
|
+
with open(task_file, "r", encoding="utf-8") as f:
|
|
632
|
+
status_str = json.load(f).get("status")
|
|
633
|
+
except (OSError, json.JSONDecodeError):
|
|
634
|
+
errors.append(f"{name}: task_state.json 无法解析")
|
|
635
|
+
continue
|
|
636
|
+
if status_str in {s.value for s in protect}:
|
|
637
|
+
protected.append(name)
|
|
638
|
+
continue
|
|
639
|
+
try:
|
|
640
|
+
mtime = os.path.getmtime(task_file)
|
|
641
|
+
except OSError:
|
|
642
|
+
errors.append(f"{name}: 无法读取 task_state.json 修改时间")
|
|
643
|
+
continue
|
|
644
|
+
if mtime > cutoff:
|
|
645
|
+
protected.append(name) # 未超龄,保留
|
|
646
|
+
continue
|
|
647
|
+
# 路径穿越防护:任务目录 realpath 必须位于工作区内
|
|
648
|
+
if not os.path.realpath(task_dir).startswith(real_working + os.sep):
|
|
649
|
+
errors.append(f"{name}: 任务目录逃逸工作区,拒绝删除")
|
|
650
|
+
continue
|
|
651
|
+
try:
|
|
652
|
+
shutil.rmtree(task_dir)
|
|
653
|
+
swept.append(name)
|
|
654
|
+
except OSError as e:
|
|
655
|
+
errors.append(f"{name}: 删除失败 ({e})")
|
|
656
|
+
|
|
657
|
+
return {"swept": swept, "protected": protected, "errors": errors}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""core.audio.subtitle — SRT 字幕生成 + moviepy 叠加(v5.0 Batch 4 / 4.3 拆分后的包入口)
|
|
2
|
+
|
|
3
|
+
SubtitleGenerator 由两个职责 mixin(SubtitleSrtMixin / SubtitleRenderMixin)
|
|
4
|
+
组合而成;对外保留全部原符号(subtitle.py 兼容 re-export)。"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from .generator import SubtitleSrtMixin
|
|
9
|
+
from .renderer import SubtitleRenderMixin
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SubtitleGenerator(
|
|
15
|
+
SubtitleSrtMixin,
|
|
16
|
+
SubtitleRenderMixin,
|
|
17
|
+
):
|
|
18
|
+
|
|
19
|
+
"""字幕生成器:cues → SRT + moviepy 叠加。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from . import generator as srt_generator
|
|
23
|
+
from . import renderer as subtitle_renderer
|
|
24
|
+
|
|
25
|
+
# 运行时注入:mixin 方法内显式 `SubtitleGenerator.xxx` 自引用
|
|
26
|
+
# (原 @staticmethod 风格)在模块级延迟解析到组合类。
|
|
27
|
+
srt_generator.SubtitleGenerator = SubtitleGenerator
|
|
28
|
+
subtitle_renderer.SubtitleGenerator = SubtitleGenerator
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SubtitleGenerator",
|
|
32
|
+
"SubtitleSrtMixin",
|
|
33
|
+
"SubtitleRenderMixin",
|
|
34
|
+
]
|
|
35
|
+
|