vcshort 0.2.0__py3-none-any.whl
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.
- vcshort/__init__.py +3 -0
- vcshort/__main__.py +6 -0
- vcshort/apply_flashback_fog.py +136 -0
- vcshort/cli.py +253 -0
- vcshort/compose_chapter.py +274 -0
- vcshort/config_manager.py +115 -0
- vcshort/extract.py +375 -0
- vcshort/fix_image.py +240 -0
- vcshort/gen_image.py +417 -0
- vcshort/gen_keyframe.py +372 -0
- vcshort/gen_shots.py +733 -0
- vcshort/gen_video.py +625 -0
- vcshort/gen_voice.py +287 -0
- vcshort/init.py +151 -0
- vcshort/reshape_image.py +244 -0
- vcshort-0.2.0.dist-info/METADATA +255 -0
- vcshort-0.2.0.dist-info/RECORD +20 -0
- vcshort-0.2.0.dist-info/WHEEL +5 -0
- vcshort-0.2.0.dist-info/entry_points.txt +2 -0
- vcshort-0.2.0.dist-info/top_level.txt +1 -0
vcshort/__init__.py
ADDED
vcshort/__main__.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""回忆/闪回视频后处理:给画面边缘叠加一圈白色雾气。"""
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
except ImportError:
|
|
13
|
+
cv2 = None
|
|
14
|
+
np = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _has_ffmpeg() -> bool:
|
|
18
|
+
try:
|
|
19
|
+
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
20
|
+
return True
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply_flashback_fog(
|
|
26
|
+
video_path: str | Path,
|
|
27
|
+
output_path: str | Path | None = None,
|
|
28
|
+
inner: float = 0.55,
|
|
29
|
+
outer: float = 1.0,
|
|
30
|
+
max_alpha: float = 0.75,
|
|
31
|
+
gamma: float = 2.0,
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""给视频叠加边缘白雾,返回输出视频路径。
|
|
34
|
+
|
|
35
|
+
参数(已按 004_01 效果固定):
|
|
36
|
+
- inner: 中心清晰区半径比例(0~1),默认 0.55
|
|
37
|
+
- outer: 雾完全覆盖区半径比例,默认 1.0
|
|
38
|
+
- max_alpha: 边缘最大雾浓度,默认 0.75
|
|
39
|
+
- gamma: 雾浓度曲线幂次,默认 2.0(越靠边越浓)
|
|
40
|
+
"""
|
|
41
|
+
if cv2 is None or np is None:
|
|
42
|
+
print("错误:回忆雾效需要 opencv-python 和 numpy,请安装:pip install opencv-python numpy", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
if not _has_ffmpeg():
|
|
45
|
+
print("错误:回忆雾效需要 ffmpeg", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
video_path = Path(video_path)
|
|
49
|
+
if output_path is None:
|
|
50
|
+
output_path = video_path
|
|
51
|
+
else:
|
|
52
|
+
output_path = Path(output_path)
|
|
53
|
+
|
|
54
|
+
# 临时文件
|
|
55
|
+
tmp_dir = Path(tempfile.gettempdir())
|
|
56
|
+
video_no_audio = tmp_dir / f"{video_path.stem}_fog_no_audio.mp4"
|
|
57
|
+
audio_path = tmp_dir / f"{video_path.stem}_audio.aac"
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# 提取音频
|
|
61
|
+
subprocess.run(
|
|
62
|
+
["ffmpeg", "-y", "-i", str(video_path), "-vn", "-c:a", "copy", str(audio_path)],
|
|
63
|
+
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 读取视频
|
|
67
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
68
|
+
if not cap.isOpened():
|
|
69
|
+
print(f"错误:无法打开视频 {video_path}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
73
|
+
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
74
|
+
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
75
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
76
|
+
out = cv2.VideoWriter(str(video_no_audio), fourcc, fps, (w, h))
|
|
77
|
+
|
|
78
|
+
# 径向雾遮罩:中心清晰,边缘白色
|
|
79
|
+
x = np.linspace(-1, 1, w)
|
|
80
|
+
y = np.linspace(-1, 1, h)
|
|
81
|
+
xx, yy = np.meshgrid(x, y)
|
|
82
|
+
dist = np.sqrt(xx**2 + yy**2)
|
|
83
|
+
alpha = np.clip((dist - inner) / (outer - inner), 0, 1)
|
|
84
|
+
alpha = (alpha ** gamma) * max_alpha
|
|
85
|
+
alpha = alpha[:, :, np.newaxis].astype(np.float32)
|
|
86
|
+
|
|
87
|
+
frame_count = 0
|
|
88
|
+
while True:
|
|
89
|
+
ret, frame = cap.read()
|
|
90
|
+
if not ret:
|
|
91
|
+
break
|
|
92
|
+
frame_f = frame.astype(np.float32)
|
|
93
|
+
fogged = frame_f * (1 - alpha) + 255.0 * alpha
|
|
94
|
+
fogged = np.clip(fogged, 0, 255).astype(np.uint8)
|
|
95
|
+
out.write(fogged)
|
|
96
|
+
frame_count += 1
|
|
97
|
+
|
|
98
|
+
cap.release()
|
|
99
|
+
out.release()
|
|
100
|
+
print(f"回忆雾效处理 {frame_count} 帧")
|
|
101
|
+
|
|
102
|
+
# 合并音频
|
|
103
|
+
subprocess.run(
|
|
104
|
+
[
|
|
105
|
+
"ffmpeg", "-y",
|
|
106
|
+
"-i", str(video_no_audio),
|
|
107
|
+
"-i", str(audio_path),
|
|
108
|
+
"-c:v", "libx264", "-c:a", "copy",
|
|
109
|
+
"-pix_fmt", "yuv420p",
|
|
110
|
+
"-shortest",
|
|
111
|
+
str(output_path),
|
|
112
|
+
],
|
|
113
|
+
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
114
|
+
)
|
|
115
|
+
print(f"已保存回忆雾效视频: {output_path}")
|
|
116
|
+
finally:
|
|
117
|
+
# 清理临时文件
|
|
118
|
+
if video_no_audio.exists():
|
|
119
|
+
video_no_audio.unlink()
|
|
120
|
+
if audio_path.exists():
|
|
121
|
+
audio_path.unlink()
|
|
122
|
+
|
|
123
|
+
return output_path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main(argv=None) -> int:
|
|
127
|
+
parser = argparse.ArgumentParser(prog="apply_flashback_fog", description="给视频叠加回忆白雾")
|
|
128
|
+
parser.add_argument("video", help="输入视频路径")
|
|
129
|
+
parser.add_argument("-o", "--output", default=None, help="输出路径(默认覆盖输入)")
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
apply_flashback_fog(args.video, args.output)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(main())
|
vcshort/cli.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""vcshort CLI 主入口 — AI 短视频制作命令行工具。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
vcshort <command> [args...]
|
|
6
|
+
|
|
7
|
+
子命令:
|
|
8
|
+
init 初始化项目
|
|
9
|
+
config-list 列出已有资产
|
|
10
|
+
extract 提取资产并匹配
|
|
11
|
+
gen-image 生成图片资产
|
|
12
|
+
gen-voice 生成角色音色
|
|
13
|
+
fix-image 修改图片资产
|
|
14
|
+
reshape-image 身材重塑(瘦胖/高矮/腿比/头比,纯几何变形)
|
|
15
|
+
gen-shots 拆分分镜
|
|
16
|
+
gen-keyframe 生成分镜首帧图
|
|
17
|
+
gen-video 生成分镜视频
|
|
18
|
+
compose-chapter 合成章节视频
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="vcshort",
|
|
28
|
+
description="AI 短视频制作 CLI",
|
|
29
|
+
)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
# init <项目名>
|
|
33
|
+
p_init = sub.add_parser("init", help="初始化项目")
|
|
34
|
+
p_init.add_argument("name", help="项目名")
|
|
35
|
+
|
|
36
|
+
# config-list <项目路径> list
|
|
37
|
+
p_cfg = sub.add_parser("config-list", help="列出已有资产")
|
|
38
|
+
p_cfg.add_argument("project", help="项目路径")
|
|
39
|
+
p_cfg.add_argument("subcommand", nargs="?", default="list", help="子命令(默认 list)")
|
|
40
|
+
|
|
41
|
+
# extract <项目路径> --chapter <章节号> [--confirm]
|
|
42
|
+
p_extract = sub.add_parser("extract", help="提取资产并匹配")
|
|
43
|
+
p_extract.add_argument("project", help="项目路径")
|
|
44
|
+
p_extract.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
45
|
+
p_extract.add_argument("--confirm", action="store_true", help="用户确认后生成 map 文件")
|
|
46
|
+
|
|
47
|
+
# gen-image <项目路径> --type --name --prompt [--form] [--size] [--model] [--force] [--gender] [--no-voice]
|
|
48
|
+
p_gen_img = sub.add_parser("gen-image", help="生成图片资产")
|
|
49
|
+
p_gen_img.add_argument("project", help="项目路径")
|
|
50
|
+
p_gen_img.add_argument("--type", required=True, choices=["character", "costume", "prop", "scene", "face", "extra"], help="资产类型")
|
|
51
|
+
p_gen_img.add_argument("--name", required=True, help="资产名称")
|
|
52
|
+
p_gen_img.add_argument("--prompt", default=None, help="生成提示词(角色类型可省略,读 character.yaml)")
|
|
53
|
+
p_gen_img.add_argument("--size", default="2K", help="图片尺寸 (2K/3K/4K)")
|
|
54
|
+
p_gen_img.add_argument("--model", default=None, help="模型 ID(默认读 config.yaml)")
|
|
55
|
+
p_gen_img.add_argument("--force", action="store_true", help="覆盖同名资产")
|
|
56
|
+
p_gen_img.add_argument("--form", default=None, help="角色形态名(仅 type=character)")
|
|
57
|
+
p_gen_img.add_argument("--gender", default=None, choices=["male", "female"], help="角色性别(仅 type=character,生成图片后自动生成音色)")
|
|
58
|
+
p_gen_img.add_argument("--no-voice", action="store_true", help="不自动生成音色(仅 type=character)")
|
|
59
|
+
p_gen_img.add_argument("--ref-image", default=None, help="参考图路径(仅参考风格,形象按提示词走;多张用逗号分隔)")
|
|
60
|
+
p_gen_img.add_argument("--designs", action="store_true", help="服装选型图:8款同风格不同设计(仅 type=costume)")
|
|
61
|
+
p_gen_img.add_argument("--age", default=None, help="年龄段(仅 type=face/extra)")
|
|
62
|
+
p_gen_img.add_argument("--hair", default=None, help="发型整体偏向(仅 type=face/extra,八格各异)")
|
|
63
|
+
p_gen_img.add_argument("--cells", type=int, default=8, choices=[4, 8], help="格图格数(仅 type=face/extra,默认8,4为四格横排)")
|
|
64
|
+
p_gen_img.add_argument("--face-image", default=None, help="面部参考图(仅 type=character,与 --costume-image 同用)")
|
|
65
|
+
p_gen_img.add_argument("--costume-image", default=None, help="服装参考图(仅 type=character,与 --face-image 同用)")
|
|
66
|
+
|
|
67
|
+
# gen-voice <项目路径> --name --gender [--voice] [--instruction] [--emotion] [--emotion-scale] [--force]
|
|
68
|
+
p_gen_voice = sub.add_parser("gen-voice", help="生成角色音色")
|
|
69
|
+
p_gen_voice.add_argument("project", help="项目路径")
|
|
70
|
+
p_gen_voice.add_argument("--name", required=True, help="角色名")
|
|
71
|
+
p_gen_voice.add_argument("--gender", required=True, choices=["male", "female"], help="角色性别")
|
|
72
|
+
p_gen_voice.add_argument("--voice", help="指定音色 ID(如 zh_male_qingcang_uranus_bigtts),不指定则随机选")
|
|
73
|
+
p_gen_voice.add_argument("--instruction", help="自然语言情感指令,控制语气语调(如 \"用凶狠霸道的语气说\")")
|
|
74
|
+
p_gen_voice.add_argument("--emotion", help="情感标签(如 angry/happy/sad),写入 audio_params.emotion")
|
|
75
|
+
p_gen_voice.add_argument("--emotion-scale", type=int, choices=range(1, 6), help="情感强度 1-5,需配合 --emotion 使用")
|
|
76
|
+
p_gen_voice.add_argument("--force", action="store_true", help="覆盖已有音色,换一个新音色")
|
|
77
|
+
|
|
78
|
+
# fix-image <项目路径> --name --prompt
|
|
79
|
+
p_fix_img = sub.add_parser("fix-image", help="修改图片资产")
|
|
80
|
+
p_fix_img.add_argument("project", help="项目路径")
|
|
81
|
+
p_fix_img.add_argument("--name", required=True, help="资产名称(角色名、角色名:形态名、场景名等)")
|
|
82
|
+
p_fix_img.add_argument("--prompt", required=True, help="修改提示词")
|
|
83
|
+
|
|
84
|
+
# reshape-image <项目路径> --name [--regions --width --waist --height --legs --head --inplace]
|
|
85
|
+
p_resh = sub.add_parser("reshape-image", help="身材重塑(瘦胖/高矮/腿比/头比,纯几何变形)")
|
|
86
|
+
p_resh.add_argument("project", help="项目路径")
|
|
87
|
+
p_resh.add_argument("--name", required=True, help="资产名称(角色名、角色名:形态名等)")
|
|
88
|
+
p_resh.add_argument("--regions", required=True, help="各视图语义坐标 JSON(必填,agent 看图标注)")
|
|
89
|
+
p_resh.add_argument("--width", type=float, default=1.0, help="整体宽窄:<1 瘦 / >1 胖")
|
|
90
|
+
p_resh.add_argument("--waist", type=float, default=1.0, help="腰部宽窄:<1 收腰 / >1 腰粗")
|
|
91
|
+
p_resh.add_argument("--height", type=float, default=1.0, help="身高:>1 变高 / <1 变矮")
|
|
92
|
+
p_resh.add_argument("--legs", type=float, default=1.0, help="腿长占比:>1 变长 / <1 变短")
|
|
93
|
+
p_resh.add_argument("--head", type=float, default=1.0, help="头部大小:<1 头小 / >1 头大")
|
|
94
|
+
p_resh.add_argument("--stretch", type=float, default=1.0, help="全身横向均匀缩放(含头部):>1 拉宽")
|
|
95
|
+
p_resh.add_argument("--inplace", action="store_true", help="覆盖原图(默认输出 -reshape.png 预览)")
|
|
96
|
+
|
|
97
|
+
# gen-shots <项目路径> --chapter [--force]
|
|
98
|
+
p_gen_shots = sub.add_parser("gen-shots", help="拆分分镜")
|
|
99
|
+
p_gen_shots.add_argument("project", help="项目路径")
|
|
100
|
+
p_gen_shots.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
101
|
+
p_gen_shots.add_argument("--force", action="store_true", help="覆盖已有分镜文件")
|
|
102
|
+
|
|
103
|
+
# gen-keyframe <项目路径> --chapter --shot [--force]
|
|
104
|
+
p_gen_keyframe = sub.add_parser("gen-keyframe", help="生成分镜首帧图")
|
|
105
|
+
p_gen_keyframe.add_argument("project", help="项目路径")
|
|
106
|
+
p_gen_keyframe.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
107
|
+
p_gen_keyframe.add_argument("--shot", required=True, help="分镜号(如 001_01 或 001)")
|
|
108
|
+
p_gen_keyframe.add_argument("--anchor", default=None, help="首帧锚点:尾帧/首帧/第一帧/完整标记/none")
|
|
109
|
+
p_gen_keyframe.add_argument("--force", action="store_true", help="覆盖已有 keyframe.png")
|
|
110
|
+
|
|
111
|
+
# gen-video <项目路径> --chapter --shot
|
|
112
|
+
p_gen_video = sub.add_parser("gen-video", help="生成分镜视频")
|
|
113
|
+
p_gen_video.add_argument("project", help="项目路径")
|
|
114
|
+
p_gen_video.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
115
|
+
p_gen_video.add_argument("--shot", required=True, help="分镜号(如 001)")
|
|
116
|
+
|
|
117
|
+
# compose-chapter <项目路径> --chapter [--output]
|
|
118
|
+
p_compose = sub.add_parser("compose-chapter", help="合成章节视频")
|
|
119
|
+
p_compose.add_argument("project", help="项目路径")
|
|
120
|
+
p_compose.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
121
|
+
p_compose.add_argument("--output", default=None, help="输出文件名(默认 chapter.mp4)")
|
|
122
|
+
p_compose.add_argument("--shots-dir", default="shots", help="分镜目录名(默认 shots)")
|
|
123
|
+
|
|
124
|
+
return parser
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv=None) -> int:
|
|
128
|
+
# Windows 控制台默认 cp1252 编码,打印中文会崩,强制 UTF-8
|
|
129
|
+
if sys.platform == "win32":
|
|
130
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
131
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
parser = build_parser()
|
|
134
|
+
args = parser.parse_args(argv)
|
|
135
|
+
cmd = args.command
|
|
136
|
+
|
|
137
|
+
if cmd == "init":
|
|
138
|
+
from . import init as init_mod
|
|
139
|
+
return init_mod.main([args.name])
|
|
140
|
+
|
|
141
|
+
if cmd == "config-list":
|
|
142
|
+
from . import config_manager
|
|
143
|
+
sub_argv = [args.project, args.subcommand]
|
|
144
|
+
return config_manager.main(sub_argv)
|
|
145
|
+
|
|
146
|
+
if cmd == "extract":
|
|
147
|
+
from . import extract as extract_mod
|
|
148
|
+
sub_argv = [args.project, "--chapter", args.chapter]
|
|
149
|
+
if args.confirm:
|
|
150
|
+
sub_argv.append("--confirm")
|
|
151
|
+
return extract_mod.main(sub_argv)
|
|
152
|
+
|
|
153
|
+
if cmd == "gen-image":
|
|
154
|
+
from . import gen_image
|
|
155
|
+
sub_argv = [args.project, "--type", args.type, "--name", args.name]
|
|
156
|
+
if args.prompt:
|
|
157
|
+
sub_argv += ["--prompt", args.prompt]
|
|
158
|
+
if args.size:
|
|
159
|
+
sub_argv += ["--size", args.size]
|
|
160
|
+
if args.model:
|
|
161
|
+
sub_argv += ["--model", args.model]
|
|
162
|
+
if args.force:
|
|
163
|
+
sub_argv.append("--force")
|
|
164
|
+
if args.form:
|
|
165
|
+
sub_argv += ["--form", args.form]
|
|
166
|
+
if args.gender:
|
|
167
|
+
sub_argv += ["--gender", args.gender]
|
|
168
|
+
if args.no_voice:
|
|
169
|
+
sub_argv.append("--no-voice")
|
|
170
|
+
if args.ref_image:
|
|
171
|
+
sub_argv += ["--ref-image", args.ref_image]
|
|
172
|
+
if args.designs:
|
|
173
|
+
sub_argv.append("--designs")
|
|
174
|
+
if args.age:
|
|
175
|
+
sub_argv += ["--age", args.age]
|
|
176
|
+
if args.hair:
|
|
177
|
+
sub_argv += ["--hair", args.hair]
|
|
178
|
+
if args.cells and args.cells != 8:
|
|
179
|
+
sub_argv += ["--cells", str(args.cells)]
|
|
180
|
+
if args.face_image:
|
|
181
|
+
sub_argv += ["--face-image", args.face_image]
|
|
182
|
+
if args.costume_image:
|
|
183
|
+
sub_argv += ["--costume-image", args.costume_image]
|
|
184
|
+
return gen_image.main(sub_argv)
|
|
185
|
+
|
|
186
|
+
if cmd == "gen-voice":
|
|
187
|
+
from . import gen_voice
|
|
188
|
+
sub_argv = [args.project, "--name", args.name, "--gender", args.gender]
|
|
189
|
+
if args.voice:
|
|
190
|
+
sub_argv += ["--voice", args.voice]
|
|
191
|
+
if args.instruction:
|
|
192
|
+
sub_argv += ["--instruction", args.instruction]
|
|
193
|
+
if args.emotion:
|
|
194
|
+
sub_argv += ["--emotion", args.emotion]
|
|
195
|
+
if args.emotion_scale:
|
|
196
|
+
sub_argv += ["--emotion-scale", str(args.emotion_scale)]
|
|
197
|
+
if args.force:
|
|
198
|
+
sub_argv.append("--force")
|
|
199
|
+
return gen_voice.main(sub_argv)
|
|
200
|
+
|
|
201
|
+
if cmd == "fix-image":
|
|
202
|
+
from . import fix_image
|
|
203
|
+
sub_argv = [args.project, "--name", args.name, "--prompt", args.prompt]
|
|
204
|
+
return fix_image.main(sub_argv)
|
|
205
|
+
|
|
206
|
+
if cmd == "reshape-image":
|
|
207
|
+
from . import reshape_image
|
|
208
|
+
sub_argv = [args.project, "--name", args.name,
|
|
209
|
+
"--width", str(args.width), "--waist", str(args.waist),
|
|
210
|
+
"--height", str(args.height), "--legs", str(args.legs),
|
|
211
|
+
"--head", str(args.head), "--stretch", str(args.stretch)]
|
|
212
|
+
if args.regions:
|
|
213
|
+
sub_argv += ["--regions", args.regions]
|
|
214
|
+
if args.inplace:
|
|
215
|
+
sub_argv.append("--inplace")
|
|
216
|
+
return reshape_image.main(sub_argv)
|
|
217
|
+
|
|
218
|
+
if cmd == "gen-shots":
|
|
219
|
+
from . import gen_shots
|
|
220
|
+
sub_argv = [args.project, "--chapter", args.chapter]
|
|
221
|
+
if args.force:
|
|
222
|
+
sub_argv.append("--force")
|
|
223
|
+
return gen_shots.main(sub_argv)
|
|
224
|
+
|
|
225
|
+
if cmd == "gen-keyframe":
|
|
226
|
+
from . import gen_keyframe
|
|
227
|
+
sub_argv = [args.project, "--chapter", args.chapter, "--shot", args.shot]
|
|
228
|
+
if args.anchor:
|
|
229
|
+
sub_argv += ["--anchor", args.anchor]
|
|
230
|
+
if args.force:
|
|
231
|
+
sub_argv.append("--force")
|
|
232
|
+
return gen_keyframe.main(sub_argv)
|
|
233
|
+
|
|
234
|
+
if cmd == "gen-video":
|
|
235
|
+
from . import gen_video
|
|
236
|
+
sub_argv = [args.project, "--chapter", args.chapter, "--shot", args.shot]
|
|
237
|
+
return gen_video.main(sub_argv)
|
|
238
|
+
|
|
239
|
+
if cmd == "compose-chapter":
|
|
240
|
+
from . import compose_chapter
|
|
241
|
+
sub_argv = [args.project, "--chapter", args.chapter]
|
|
242
|
+
if args.output:
|
|
243
|
+
sub_argv += ["--output", args.output]
|
|
244
|
+
if args.shots_dir:
|
|
245
|
+
sub_argv += ["--shots-dir", args.shots_dir]
|
|
246
|
+
return compose_chapter.main(sub_argv)
|
|
247
|
+
|
|
248
|
+
parser.print_help()
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
sys.exit(main())
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""合成章节视频:将一个章节下所有分镜的 shot.mp4 按顺序拼接成一个完整视频。
|
|
3
|
+
|
|
4
|
+
用 imageio-ffmpeg 自带的 ffmpeg 二进制拼接,不依赖系统安装 ffmpeg。
|
|
5
|
+
编码为 H.264,兼容所有播放器。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
vcshort compose-chapter <项目路径> --chapter <章节号> [--output <文件名>]
|
|
9
|
+
|
|
10
|
+
输出默认放在 chapters/<章节号>/chapter.mp4
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import cv2
|
|
20
|
+
except ImportError:
|
|
21
|
+
print("错误:需要 opencv-python(pip install opencv-python)", file=sys.stderr)
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from imageio_ffmpeg import get_ffmpeg_exe
|
|
26
|
+
FFMPEG = get_ffmpeg_exe()
|
|
27
|
+
except ImportError:
|
|
28
|
+
print("错误:需要 imageio-ffmpeg(pip install imageio-ffmpeg)", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_shot_videos(chapter_dir: Path, shots_dir_name: str = "shots") -> list:
|
|
33
|
+
"""按分镜号顺序查找指定分镜目录下的所有 shot.mp4 文件。"""
|
|
34
|
+
shots_dir = chapter_dir / shots_dir_name
|
|
35
|
+
if not shots_dir.is_dir():
|
|
36
|
+
return []
|
|
37
|
+
videos = []
|
|
38
|
+
for shot_dir in sorted(shots_dir.iterdir()):
|
|
39
|
+
if shot_dir.is_dir() and shot_dir.name.startswith("shot_"):
|
|
40
|
+
video = shot_dir / "shot.mp4"
|
|
41
|
+
if video.exists():
|
|
42
|
+
videos.append(video)
|
|
43
|
+
return videos
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_video_info(video_path: Path) -> dict:
|
|
47
|
+
"""用 opencv 获取视频信息。"""
|
|
48
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
49
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
|
|
50
|
+
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
51
|
+
info = {
|
|
52
|
+
"fps": fps,
|
|
53
|
+
"width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
|
54
|
+
"height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
|
55
|
+
"frames": frames,
|
|
56
|
+
"duration": frames / fps if fps else 0.0,
|
|
57
|
+
}
|
|
58
|
+
cap.release()
|
|
59
|
+
return info
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def has_audio(video_path: Path) -> bool:
|
|
63
|
+
"""检测视频是否包含音频流,供黑屏片段补齐静音音轨。"""
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
[FFMPEG, "-i", str(video_path), "-f", "null", "-"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
)
|
|
69
|
+
return "Audio:" in result.stderr
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scene_id(video_path: Path) -> str:
|
|
73
|
+
"""从 shot_001_09 目录名提取场景主编号 001。"""
|
|
74
|
+
name = video_path.parent.name.removeprefix("shot_")
|
|
75
|
+
return name.split("_", 1)[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scene_name(video_path: Path) -> str:
|
|
79
|
+
"""读取分镜 YAML 中的场景名,用作转场提示。"""
|
|
80
|
+
yaml_path = video_path.parent / "shot.yaml"
|
|
81
|
+
if yaml_path.exists():
|
|
82
|
+
for line in yaml_path.read_text(encoding="utf-8").splitlines():
|
|
83
|
+
if line.startswith("scene:"):
|
|
84
|
+
return line.split(":", 1)[1].strip().strip("'\\\"")
|
|
85
|
+
return scene_id(video_path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_font() -> str | None:
|
|
89
|
+
"""查找可显示中文的字体;找不到时交给 ffmpeg 默认字体。"""
|
|
90
|
+
candidates = [
|
|
91
|
+
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
|
92
|
+
"/System/Library/Fonts/STHeiti Medium.ttc",
|
|
93
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
94
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
95
|
+
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
|
96
|
+
]
|
|
97
|
+
return next((path for path in candidates if Path(path).exists()), None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def escape_drawtext(text: str) -> str:
|
|
101
|
+
"""转义 ffmpeg drawtext 的文本内容。"""
|
|
102
|
+
return text.replace("\\\\", "\\\\\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_ffmpeg(command: list) -> None:
|
|
106
|
+
result = subprocess.run(command, capture_output=True, text=True)
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
print(f"ffmpeg 错误:\n{result.stderr}", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_clip(
|
|
113
|
+
video_path: Path,
|
|
114
|
+
output_path: Path,
|
|
115
|
+
info: dict,
|
|
116
|
+
audio: bool,
|
|
117
|
+
fade_in: float = 0.0,
|
|
118
|
+
fade_out: float = 0.0,
|
|
119
|
+
scene_label: str = "",
|
|
120
|
+
) -> None:
|
|
121
|
+
"""为场景边界添加淡入/淡出黑屏效果和场景提示,并统一编码参数。"""
|
|
122
|
+
filters = []
|
|
123
|
+
if fade_in > 0:
|
|
124
|
+
filters.append(f"fade=t=in:st=0:d={fade_in:.3f}:color=black")
|
|
125
|
+
if fade_out > 0:
|
|
126
|
+
start = max(0.0, info["duration"] - fade_out)
|
|
127
|
+
filters.append(f"fade=t=out:st={start:.3f}:d={fade_out:.3f}:color=black")
|
|
128
|
+
if scene_label:
|
|
129
|
+
label_start = 0.0
|
|
130
|
+
label_fade = 0.4
|
|
131
|
+
label_hold_end = label_fade + 1.0
|
|
132
|
+
label_end = label_hold_end + label_fade
|
|
133
|
+
alpha = (
|
|
134
|
+
f"if(lt(t\\,{label_fade})\\,t/{label_fade}\\,"
|
|
135
|
+
f"if(lt(t\\,{label_hold_end})\\,1\\,"
|
|
136
|
+
f"if(lt(t\\,{label_end})\\,({label_end}-t)/{label_fade}\\,0)))"
|
|
137
|
+
)
|
|
138
|
+
font = find_font()
|
|
139
|
+
font_option = f":fontfile='{font}'" if font else ""
|
|
140
|
+
text = escape_drawtext(scene_label)
|
|
141
|
+
filters.append(
|
|
142
|
+
f"drawtext=text='{text}'{font_option}:x=40:y=40:fontsize=48:"
|
|
143
|
+
f"fontcolor=white:borderw=2:bordercolor=black@0.8:shadowx=2:shadowy=2:"
|
|
144
|
+
f"shadowcolor=black@0.65:alpha='{alpha}'"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
command = [FFMPEG, "-y", "-i", str(video_path)]
|
|
148
|
+
clip_has_audio = has_audio(video_path)
|
|
149
|
+
if audio and not clip_has_audio:
|
|
150
|
+
# 无声镜头补静音轨,保证 concat 后整片音轨连续
|
|
151
|
+
command += ["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"]
|
|
152
|
+
if filters:
|
|
153
|
+
command += ["-vf", ",".join(filters)]
|
|
154
|
+
command += ["-map", "0:v:0", "-c:v", "libx264", "-crf", "18", "-preset", "fast"]
|
|
155
|
+
if audio:
|
|
156
|
+
if clip_has_audio:
|
|
157
|
+
command += ["-map", "0:a:0?"]
|
|
158
|
+
else:
|
|
159
|
+
command += ["-map", "1:a:0", "-shortest"]
|
|
160
|
+
command += ["-c:a", "aac", "-ar", "48000", "-ac", "2"]
|
|
161
|
+
else:
|
|
162
|
+
command += ["-an"]
|
|
163
|
+
command += ["-r", str(info["fps"]), "-pix_fmt", "yuv420p", str(output_path)]
|
|
164
|
+
run_ffmpeg(command)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_black(output_path: Path, info: dict, audio: bool, duration: float) -> None:
|
|
168
|
+
"""生成场景之间的短暂黑屏,音频存在时补静音。"""
|
|
169
|
+
size = f"{info['width']}x{info['height']}"
|
|
170
|
+
command = [
|
|
171
|
+
FFMPEG, "-y",
|
|
172
|
+
"-f", "lavfi", "-i", f"color=c=black:s={size}:r={info['fps']}:d={duration:.3f}",
|
|
173
|
+
]
|
|
174
|
+
if audio:
|
|
175
|
+
command += [
|
|
176
|
+
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000",
|
|
177
|
+
"-t", f"{duration:.3f}", "-map", "0:v:0", "-map", "1:a:0",
|
|
178
|
+
"-c:a", "aac", "-ar", "48000", "-ac", "2",
|
|
179
|
+
]
|
|
180
|
+
else:
|
|
181
|
+
command += ["-map", "0:v:0", "-an"]
|
|
182
|
+
command += [
|
|
183
|
+
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
|
|
184
|
+
"-r", str(info["fps"]), "-pix_fmt", "yuv420p", str(output_path),
|
|
185
|
+
]
|
|
186
|
+
run_ffmpeg(command)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def compose(videos: list, output_path: Path) -> None:
|
|
190
|
+
"""拼接视频,并在场景边界淡出黑屏、短暂黑屏后淡入下一场景。"""
|
|
191
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
fade_duration = 0.25
|
|
193
|
+
black_duration = 0.0
|
|
194
|
+
info = get_video_info(videos[0])
|
|
195
|
+
audio = any(has_audio(v) for v in videos)
|
|
196
|
+
|
|
197
|
+
with tempfile.TemporaryDirectory(prefix="compose_") as temp_dir:
|
|
198
|
+
temp_dir = Path(temp_dir)
|
|
199
|
+
rendered = []
|
|
200
|
+
for index, video in enumerate(videos):
|
|
201
|
+
previous_scene = scene_id(videos[index - 1]) if index else None
|
|
202
|
+
next_scene = scene_id(videos[index + 1]) if index + 1 < len(videos) else None
|
|
203
|
+
current_scene = scene_id(video)
|
|
204
|
+
is_scene_start = index > 0 and current_scene != previous_scene
|
|
205
|
+
is_scene_end = index + 1 < len(videos) and current_scene != next_scene
|
|
206
|
+
clip_info = get_video_info(video)
|
|
207
|
+
clip_path = temp_dir / f"clip_{index:04d}.mp4"
|
|
208
|
+
render_clip(
|
|
209
|
+
video,
|
|
210
|
+
clip_path,
|
|
211
|
+
clip_info,
|
|
212
|
+
audio,
|
|
213
|
+
fade_in=min(fade_duration, clip_info["duration"] / 2) if is_scene_start else 0.0,
|
|
214
|
+
fade_out=min(fade_duration, clip_info["duration"] / 2) if is_scene_end else 0.0,
|
|
215
|
+
scene_label=scene_name(video) if is_scene_start else "",
|
|
216
|
+
)
|
|
217
|
+
rendered.append(clip_path)
|
|
218
|
+
if is_scene_end and black_duration > 0:
|
|
219
|
+
black_path = temp_dir / f"black_{index:04d}.mp4"
|
|
220
|
+
render_black(black_path, info, audio, black_duration)
|
|
221
|
+
rendered.append(black_path)
|
|
222
|
+
|
|
223
|
+
list_file = temp_dir / "concat_list.txt"
|
|
224
|
+
with list_file.open("w", encoding="utf-8") as file:
|
|
225
|
+
for clip in rendered:
|
|
226
|
+
escaped = str(clip).replace("'", "'\\''")
|
|
227
|
+
file.write(f"file '{escaped}'\n")
|
|
228
|
+
|
|
229
|
+
run_ffmpeg([
|
|
230
|
+
FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", str(list_file),
|
|
231
|
+
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
|
|
232
|
+
"-r", str(info["fps"]), "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
|
233
|
+
*(["-c:a", "aac", "-ar", "48000", "-ac", "2"] if audio else ["-an"]),
|
|
234
|
+
str(output_path),
|
|
235
|
+
])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv=None) -> int:
|
|
239
|
+
parser = argparse.ArgumentParser(prog="vcshort compose-chapter", description="合成章节视频")
|
|
240
|
+
parser.add_argument("project", help="项目路径")
|
|
241
|
+
parser.add_argument("--chapter", required=True, help="章节号(如 ch01)")
|
|
242
|
+
parser.add_argument("--output", default=None, help="输出文件名(默认 chapter.mp4)")
|
|
243
|
+
parser.add_argument("--shots-dir", default="shots", help="分镜目录名(默认 shots)")
|
|
244
|
+
args = parser.parse_args(argv)
|
|
245
|
+
|
|
246
|
+
project_root = Path(args.project).resolve()
|
|
247
|
+
if not project_root.exists():
|
|
248
|
+
print(f"错误:项目路径不存在: {project_root}", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
chapter_dir = project_root / "chapters" / args.chapter
|
|
252
|
+
if not chapter_dir.is_dir():
|
|
253
|
+
print(f"错误:章节目录不存在: {chapter_dir}", file=sys.stderr)
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
videos = find_shot_videos(chapter_dir, args.shots_dir)
|
|
257
|
+
if not videos:
|
|
258
|
+
print(f"错误:{chapter_dir}/{args.shots_dir}/ 下没有找到 shot.mp4 文件", file=sys.stderr)
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
print(f"找到 {len(videos)} 个分镜视频:")
|
|
262
|
+
for v in videos:
|
|
263
|
+
print(f" {v}")
|
|
264
|
+
|
|
265
|
+
output_name = args.output or "chapter.mp4"
|
|
266
|
+
output_path = chapter_dir / output_name
|
|
267
|
+
|
|
268
|
+
print(f"\n正在合成...")
|
|
269
|
+
compose(videos, output_path)
|
|
270
|
+
|
|
271
|
+
print(f"\n✅ 章节视频合成完成")
|
|
272
|
+
print(f" 输出: {output_path}")
|
|
273
|
+
print(f" 分镜数: {len(videos)}")
|
|
274
|
+
return 0
|