MyGICA 0.1.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.
MyGICA/A_compiler.py ADDED
@@ -0,0 +1,1052 @@
1
+ import hashlib
2
+ import json
3
+ import math
4
+ import os
5
+ import shutil
6
+ import tomllib
7
+ from collections import Counter
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass, field
10
+ from fractions import Fraction
11
+ from pathlib import Path
12
+ from pprint import pformat
13
+ from typing import Literal, Optional, Union
14
+
15
+ import click
16
+ import numpy as np
17
+ from PIL import Image
18
+ from loguru import logger
19
+
20
+ from .betterer import subprocess_run
21
+ from .structure import check, describe_range, parse_config, parse_fps, Clip, Range, Text, ProjectConfig
22
+ from .time_based_cache_cleaner import TimeBasedCache
23
+
24
+
25
+ @dataclass
26
+ class ScriptConfig:
27
+ MyGICA_path: Path
28
+ project: ProjectConfig = None
29
+ output: Path = None
30
+ root: Path = None # 项目根目录,相对路径的解析基准,默认取 TOML 所在目录
31
+ fontfile: Path = Path("SC-Heavy.otf")
32
+ video_width: int = 1920
33
+ video_height: int = 1080
34
+ cache_dir: Path = Path('cache_dir')
35
+ output_dir: Path = Path('output_dir')
36
+ range_spec: Optional[str] = None # 只渲染指定 Range,写法 3 或 3-5,1 起数,用于局部预览
37
+ min_font_ratio: float = 0.03 # 字号相对屏高的下限,低于就告警
38
+ verify: bool = True # 是否把每个 clip 的首中末帧导出到校验目录
39
+ verify_dir: Path = Path('verify_dir') # 校验帧的落盘目录,可以随时整个删掉
40
+ video_preset: list[str] = field(default_factory=lambda: ['-c:v', 'hevc_nvenc', '-cq', '18', '-pix_fmt', 'p010le'])
41
+ # 走 concat 滤镜时音频也必须一起编码,-c:a copy 与滤镜链不能共存
42
+ video_preset_cat: list[str] = field(default_factory=lambda: ['-c:v', 'hevc_nvenc', '-cq', '18', '-pix_fmt', 'p010le', '-c:a', 'aac', '-b:a', '192k'])
43
+
44
+ def __post_init__(self):
45
+ check(self.MyGICA_path.suffixes[-2:] == ['.MyGICA', '.toml'], 'need .MyGICA.toml file')
46
+ check(self.MyGICA_path.exists(), '.MyGICA.toml file should exists')
47
+
48
+ # 相对路径统一以项目根目录为基准,默认取 TOML 所在目录
49
+ if self.root is None:
50
+ self.root = self.MyGICA_path.resolve().parent
51
+ else:
52
+ self.root = Path(self.root).resolve()
53
+ self.fontfile = resolve_path(self.root, self.fontfile)
54
+ self.cache_dir = resolve_path(self.root, self.cache_dir)
55
+ self.output_dir = resolve_path(self.root, self.output_dir)
56
+ self.verify_dir = resolve_path(self.root, self.verify_dir)
57
+ check(self.fontfile.exists(), f'font file not found: {self.fontfile}')
58
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
59
+ self.output_dir.mkdir(parents=True, exist_ok=True)
60
+ if self.verify:
61
+ self.verify_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ # =============================
64
+ # 解析配置文件,并且生成 ProjectConfig 对象时排除不合法的情况
65
+ # =============================
66
+ with self.MyGICA_path.open('rb') as f:
67
+ self.project = parse_config(tomllib.load(f))
68
+
69
+ # sources 里的相对路径同样相对项目根目录解析
70
+ self.project.sources = {
71
+ key: str(resolve_path(self.root, Path(value)))
72
+ for key, value in self.project.sources.items()
73
+ }
74
+
75
+ # 文本可以各自换字体,相对路径同样以项目根为基准
76
+ for rng in self.project.ranges:
77
+ for text in rng.texts:
78
+ if text.fontfile:
79
+ resolved = resolve_path(self.root, Path(text.fontfile))
80
+ check(resolved.exists(), f"字体文件不存在: {resolved}")
81
+ text.fontfile = str(resolved)
82
+
83
+ check(self.project.project_suffix in {'.mp4', '.mkv', '.mov'}, 'output file should be .mp4/.mkv/.mov')
84
+
85
+ # 只渲染指定的 Range 时先裁剪再算输出名,出来的片子只覆盖这一段,不会盖掉完整版
86
+ output_suffix = ''
87
+ # 报错与告警一律按原配置里的 Range 序号报,裁剪后也不会串位
88
+ range_numbers = list(range(1, len(self.project.ranges) + 1))
89
+ if self.range_spec:
90
+ selected = parse_range_spec(self.range_spec, len(self.project.ranges))
91
+ range_numbers = [index + 1 for index in selected]
92
+ self.project.ranges = [self.project.ranges[index] for index in selected]
93
+ self.project.start = self.project.ranges[0].start
94
+ self.project.end = self.project.ranges[-1].end
95
+ output_suffix = f'_r{self.range_spec}'
96
+ logger.info(f"🎯 只渲染选中的 {len(self.project.ranges)} 个 Range,成片范围 {self.project.start}-{self.project.end}")
97
+
98
+ # 只取文件名再拼到 output_dir,避免 MyGICA_path 是绝对路径时把输出目录整个盖掉
99
+ output_name = self.MyGICA_path.with_suffix(self.project.project_suffix)
100
+ if output_suffix:
101
+ output_name = output_name.with_stem(output_name.stem + output_suffix)
102
+ self.output = self.output_dir / output_name.name
103
+
104
+ # 字号下限按工作区的画面字号规范,低于屏高 3% 的小字读不清
105
+ for position, rng in enumerate(self.project.ranges):
106
+ for text_index, text in enumerate(rng.texts, start=1):
107
+ index = range_numbers[position]
108
+ ratio = text.fontsize / self.video_height
109
+ if ratio < self.min_font_ratio:
110
+ logger.warning(
111
+ f"字号低于下限:{describe_range(index, rng)} 的第 {text_index} 条 Text "
112
+ f"fontsize={text.fontsize},占屏高 {ratio:.1%},下限 {self.min_font_ratio:.0%}"
113
+ )
114
+
115
+ # fps 只接受整数或分数写法,23.976 与 29.97 这类浮点表示会在 parse_fps 里直接报错
116
+ parse_fps(self.project.fps)
117
+ check(shutil.which('ffmpeg') is not None, 'should install ffmpeg and make sure it is in PATH')
118
+
119
+
120
+ # =============================
121
+ # 工具函数
122
+ # =============================
123
+ cache_instance = TimeBasedCache.get_instance()
124
+
125
+
126
+ def subprocess_run_cache(cmd: list[str], files: list[Path], stream_terminal: bool = True):
127
+ """带缓存的 subprocess_run,执行命令后更新文件的时间戳"""
128
+ subprocess_run(cmd, stream_terminal=stream_terminal)
129
+ cache_instance.update(files)
130
+
131
+
132
+ def frame_to_timestamp(frame: int, fps: Union[str, Literal['24000/1001']]) -> str:
133
+ total_seconds = frame_to_time(frame, fps)
134
+ ms = int((total_seconds - int(total_seconds)) * 1000)
135
+ s = int(total_seconds)
136
+ h = s // 3600
137
+ m = (s % 3600) // 60
138
+ s = s % 60
139
+ return f"{h:02}:{m:02}:{s:02}.{ms:03}"
140
+
141
+
142
+ def resolve_path(root: Path, path: Path) -> Path:
143
+ """相对路径按项目根目录解析,并统一规范化为绝对路径"""
144
+ path = Path(path)
145
+ resolved = path if path.is_absolute() else (root / path)
146
+ return resolved.resolve()
147
+
148
+
149
+ def parse_range_spec(spec: str, total: int) -> list[int]:
150
+ """解析 1 起数的 Range 选择,接受 3 与 3-5 两种写法,返回 0 起数的下标列表"""
151
+ text = spec.strip()
152
+ check(text != '', '--range 不能为空')
153
+ if '-' in text:
154
+ head, _, tail = text.partition('-')
155
+ start, end = parse_range_index(head, total), parse_range_index(tail, total)
156
+ check(start <= end, f"--range 的起点不能大于终点: {spec}")
157
+ return list(range(start, end + 1))
158
+ return [parse_range_index(text, total)]
159
+
160
+
161
+ def parse_range_index(raw: str, total: int) -> int:
162
+ """把 1 起数的 Range 序号转成 0 起数下标,并检查是否越界"""
163
+ text = raw.strip()
164
+ check(text.isdigit(), f"--range 只接受数字写法: {raw!r}")
165
+ index = int(text) - 1
166
+ check(0 <= index < total, f"--range 超出范围,共有 {total} 个 Range: {raw}")
167
+ return index
168
+
169
+
170
+ def probe_frames(path: Path) -> tuple[list[int], Fraction]:
171
+ """一次 ffprobe 拿到全部帧的时间戳与帧率,帧数就是时间戳的个数
172
+
173
+ 读的是包不是帧:包的时间戳就在容器的索引里,不用把画面解出来,995 帧的成片从五秒半
174
+ 降到七十毫秒。一个包一帧是 mp4、mkv、mov 的常态,所以包的个数就是帧数。
175
+ 包按解码顺序排,带 B 帧时前后会乱,排序之后才是显示顺序。
176
+ """
177
+ cmd = [
178
+ 'ffprobe', '-v', 'error',
179
+ '-select_streams', 'v:0',
180
+ '-show_entries', 'stream=r_frame_rate:packet=pts',
181
+ '-of', 'json',
182
+ path.as_posix(),
183
+ ]
184
+ res = subprocess_run(cmd, stream_terminal=False)
185
+ check(res.returncode == 0, f"ffprobe 读取失败: {path}\n{res.stderr[-500:]}")
186
+ data = json.loads(res.stdout)
187
+ streams = data.get('streams', [])
188
+ check(len(streams) > 0, f"ffprobe 未返回视频流: {path}")
189
+ raw_rate = streams[0].get('r_frame_rate', '')
190
+ check('/' in raw_rate, f"ffprobe 未返回帧率: {path}\n{res.stdout[:200]}")
191
+ pts: list[int] = []
192
+ for packet in data.get('packets', []):
193
+ value = str(packet.get('pts', ''))
194
+ check(value.lstrip('-').isdigit(), f"ffprobe 返回了无法解析的时间戳 {value!r}: {path}")
195
+ pts.append(int(value))
196
+ check(pts, f"ffprobe 未返回任何帧: {path}")
197
+ return sorted(pts), Fraction(raw_rate)
198
+
199
+
200
+ def probe_video(path: Path) -> tuple[int, Fraction]:
201
+ """读出视频的帧数与帧率"""
202
+ pts, fps = probe_frames(path)
203
+ return len(pts), fps
204
+
205
+
206
+ def check_frame(path: Path) -> int:
207
+ """数出视频里实际有多少帧,用于校验渲染结果与配置声明是否一致"""
208
+ return probe_video(path)[0]
209
+
210
+
211
+ def clip_label(rng_start: int, index: int, clip: Clip) -> str:
212
+ """校验帧的文件名前缀,带上片段身份,换了取帧就不会复用上一次留下的图"""
213
+ identity = f"{clip.source}:{clip.start}:{clip.end}:{clip.filters or ''}"
214
+ return f"{rng_start:04d}_{index:02d}_{hashlib.md5(identity.encode()).hexdigest()[:6]}"
215
+
216
+
217
+ def export_clip_frames(clip_file: Path, config: ScriptConfig, frame_count: int, label: str) -> list[Path]:
218
+ """把片段的首、中、末三帧导出到校验目录,方便逐个 clip 核对取到的画面
219
+
220
+ 只抽一个时间点很容易看走眼,三帧能看出这一段是不是稳定的、有没有夹到转场。
221
+ 已经存在的帧直接复用,所以重复编译不会重跑 ffmpeg。
222
+ """
223
+ if not config.verify:
224
+ return []
225
+ indices = sorted({0, frame_count // 2, frame_count - 1})
226
+ outputs: list[Path] = []
227
+ for index in indices:
228
+ target = config.verify_dir / f"{label}_{index:04d}.png"
229
+ if target.exists() and target.stat().st_size > 0:
230
+ outputs.append(target)
231
+ continue
232
+ cmd = [
233
+ 'ffmpeg', '-y', '-hide_banner',
234
+ '-i', clip_file.as_posix(),
235
+ '-vf', f"select='eq(n\\,{index})'",
236
+ '-frames:v', '1',
237
+ target.as_posix(),
238
+ ]
239
+ subprocess_run(cmd, stream_terminal=False)
240
+ check(target.exists() and target.stat().st_size > 0, f"抽取校验帧失败: {target}")
241
+ outputs.append(target)
242
+ logger.info(f"🖼️ 校验帧 {label}: " + ', '.join(p.name for p in outputs))
243
+ return outputs
244
+
245
+
246
+ def find_gaps(pts: list[int]) -> list[int]:
247
+ """找出相邻帧时间戳不是标准步长的位置,返回这些帧的序号
248
+
249
+ 步长取众数,所以不必知道容器的时间基。
250
+ """
251
+ if len(pts) < 3:
252
+ return []
253
+ diffs = [b - a for a, b in zip(pts, pts[1:])]
254
+ one = Counter(diffs).most_common(1)[0][0]
255
+ return [i for i, d in enumerate(diffs) if d != one]
256
+
257
+
258
+ def probe_gaps(path: Path) -> list[int]:
259
+ """只查某个文件的时间戳连续性,校验主路径走 probe_frames,这里留给单独调用的场合"""
260
+ return find_gaps(probe_frames(path)[0])
261
+
262
+
263
+ def check_video(path: Path, expect_frames: int, expect_fps: str, where: str) -> None:
264
+ """同时校验帧数、帧率与时间戳连续性,改帧数的滤镜和拼接留下的空档都在这里拦下"""
265
+ pts, fps = probe_frames(path)
266
+ frames = len(pts)
267
+ expect = Fraction(*parse_fps(expect_fps))
268
+ check(frames == expect_frames, f"{where} 帧数不匹配:期望 {expect_frames} 帧,实际 {frames} 帧")
269
+ check(
270
+ fps == expect,
271
+ f"{where} 帧率不匹配:期望 {expect_fps} ({float(expect):.3f}),实际 {fps} ({float(fps):.3f}),检查 Clip.filters",
272
+ )
273
+ gaps = find_gaps(pts)
274
+ check(
275
+ not gaps,
276
+ f"{where} 时间戳有 {len(gaps)} 处不连续,出现在第 {gaps[:5]} 帧之后,成片会在这些位置定格一帧",
277
+ )
278
+
279
+
280
+ def frame_to_time(frame: int, fps: Union[str, Literal['24000/1001']]) -> float:
281
+ """帧转时间,单位为秒"""
282
+ check(frame >= 0, 'frame should >= 0')
283
+ num, denom = parse_fps(fps)
284
+ return frame * denom / num
285
+
286
+
287
+ def escape_toml_string(s: str) -> str:
288
+ """转义字符串用于 drawtext"""
289
+ return s.replace("'", r"\'").replace(":", r"\:")
290
+
291
+
292
+ def escape_filter_path(path: Path) -> str:
293
+ """ffmpeg 滤镜里冒号是选项分隔符,Windows 盘符必须转义,并用单引号包住整个路径"""
294
+ escaped = str(path).replace('\\', '/').replace(':', r'\:')
295
+ return f"'{escaped}'"
296
+
297
+
298
+ def frame_to_ass_time(frame: int, fps: Union[str, Literal['24000/1001']]) -> str:
299
+ """帧转 ASS 时间戳,格式 H:MM:SS.cc,ASS 只精确到厘秒"""
300
+ total_seconds = frame_to_time(frame, fps)
301
+ centiseconds = int(round(total_seconds * 100))
302
+ seconds, cs = divmod(centiseconds, 100)
303
+ hours, rem = divmod(seconds, 3600)
304
+ minutes, seconds = divmod(rem, 60)
305
+ return f"{hours}:{minutes:02}:{seconds:02}.{cs:02}"
306
+
307
+
308
+ def escape_ass_text(s: str) -> str:
309
+ """转义字符串用于 ASS 字幕,大括号是覆盖标签的定界符,换行写成 ASS 的 \\N"""
310
+ return s.replace('{', '{').replace('}', '}').replace('\n', r'\N')
311
+
312
+
313
+ def build_reason_ass(config: ScriptConfig) -> Optional[str]:
314
+ """为每个带 reason 的 Clip 生成一条 ASS 对话,全部 Clip 都没有 reason 时返回 None"""
315
+ project = config.project
316
+ dialogues = []
317
+ for rng in project.ranges:
318
+ # 视频时间轴从 project.start 开始算,Range 的 start 是项目时间轴的绝对帧
319
+ now = rng.start - project.start
320
+ for clip in rng.clips:
321
+ length = clip.end - clip.start
322
+ if clip.reason:
323
+ start = frame_to_ass_time(now, project.fps)
324
+ end = frame_to_ass_time(now + length, project.fps)
325
+ dialogues.append(f"Dialogue: 0,{start},{end},Reason,,0,0,0,,{escape_ass_text(clip.reason)}")
326
+ now += length
327
+ if not dialogues:
328
+ return None
329
+ header = (
330
+ "[Script Info]\n"
331
+ "ScriptType: v4.00+\n"
332
+ f"PlayResX: {config.video_width}\n"
333
+ f"PlayResY: {config.video_height}\n"
334
+ "WrapStyle: 0\n"
335
+ "\n"
336
+ "[V4+ Styles]\n"
337
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
338
+ "Style: Reason,微软雅黑,42,&H00FFFFFF,&H000000FF,&H00202020,&H00000000,0,0,0,0,100,100,0,0,1,2,1,8,20,20,60,1\n"
339
+ "\n"
340
+ "[Events]\n"
341
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
342
+ )
343
+ return header + "\n".join(dialogues) + "\n"
344
+
345
+
346
+ def write_reason_subtitle(config: ScriptConfig) -> Optional[Path]:
347
+ """生成与输出视频同名的外挂字幕,用来在播放时实时查看每个画面的选取理由"""
348
+ content = build_reason_ass(config)
349
+ if content is None:
350
+ return None
351
+ ass_path = config.output.with_suffix('.ass')
352
+ # 带 BOM 保存,避免部分播放器把中文识别成乱码
353
+ ass_path.write_text(content, encoding='utf-8-sig')
354
+ logger.info(f"📝 已生成选帧理由外挂字幕: {ass_path}")
355
+ return ass_path
356
+
357
+
358
+ def is_image(file_path: Path) -> bool:
359
+ """判断文件是否为图片格式"""
360
+ image_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp'}
361
+ return file_path.suffix.lower() in image_extensions
362
+
363
+
364
+ def build_drawtext_filters(
365
+ texts: list[Text], project: ProjectConfig, fontfile: Path
366
+ ) -> str:
367
+ """
368
+ 构建 ffmpeg drawtext 滤镜字符串,使用指定字体文件,避免 fontconfig 崩溃
369
+ 参数:
370
+ texts: 字幕列表,每个元素包含 text, fontsize, fontcolor, y, borderw, bordercolor
371
+ fontfile: 字体文件路径(支持 .ttf, .otf)
372
+ video_width, video_height: 输出分辨率
373
+ 返回:
374
+ drawtext 滤镜字符串
375
+ """
376
+ filters: list[str] = []
377
+ for txt in texts:
378
+ # 提取参数,带默认值
379
+ text_str = escape_toml_string(txt.text)
380
+ fontcolor = txt.fontcolor
381
+ fontsize = txt.fontsize
382
+ x = txt.x
383
+ y = txt.y
384
+ borderw = txt.borderw
385
+ bordercolor = txt.bordercolor
386
+
387
+ if fontcolor in project.colors:
388
+ fontcolor = project.colors[fontcolor]
389
+
390
+ if txt.align == 'center':
391
+ xy = [
392
+ f"x={x}-text_w/2", # 居中
393
+ f"y={y}-text_h/2",
394
+ ]
395
+ elif txt.align == 'upper left':
396
+ xy = [
397
+ f"x={x}", # 左上角对齐
398
+ f"y={y}",
399
+ ]
400
+
401
+ # 构建 drawtext 参数
402
+ dt_args = \
403
+ [
404
+ f"fontfile={escape_filter_path(Path(txt.fontfile) if txt.fontfile else fontfile)}",
405
+ f"text='{text_str}'", # 显示文本
406
+ f"fontcolor={fontcolor}",
407
+ f"fontsize={fontsize}",
408
+ ] + xy + [
409
+ f"borderw={borderw}",
410
+ f"bordercolor={bordercolor}",
411
+ f"shadowx={txt.shadowx}",
412
+ f"shadowy={txt.shadowy}",
413
+ f"shadowcolor={txt.shadowcolor}",
414
+ ]
415
+ # 自定义参数排在最后,ffmpeg 同名选项后者覆盖前者,所以能盖掉上面的默认值
416
+ if txt.drawtext:
417
+ dt_args.append(txt.drawtext)
418
+ filters.append(f"drawtext={':'.join(dt_args)}")
419
+
420
+ return ",".join(filters)
421
+
422
+
423
+ # =============================
424
+ # 缓存剪辑
425
+ # =============================
426
+ def cache_clip(cmd: list[str], files: list[Path], cache: bool = True, stream_terminal: bool = True) -> Path:
427
+ """使用命令签名缓存剪辑,要求 cmd 最后一个参数为输出文件"""
428
+
429
+ def check(path: Path) -> bool:
430
+ res = subprocess_run(['ffprobe', '-hide_banner', '-print_format', 'json', '-show_format', '-show_streams', path], stream_terminal=False)
431
+ if res.returncode != 0:
432
+ return False
433
+ j = json.loads(res.stdout)
434
+ if 'streams' not in j or len(j['streams']) == 0:
435
+ return False
436
+ return True
437
+
438
+ # 获取输出文件
439
+ output_file = cmd[-1]
440
+ output_path = Path(output_file)
441
+ if cache:
442
+ # 生成命令签名,保存在文件名中
443
+ cmd_signature = hashlib.md5(' '.join(cmd[:-1]).encode()).hexdigest()
444
+ new_output_path = output_path.with_suffix(f'.{cmd_signature[:6]}{output_path.suffix}')
445
+ for file in files:
446
+ if not file.exists():
447
+ raise FileNotFoundError(f"剪辑时发现文件不存在:{file}")
448
+ # 如果签名文件存在且大小大于0则跳过
449
+ if new_output_path.exists() and new_output_path.stat().st_size > 0 and check(new_output_path):
450
+ logger.info(f"⏭️ 使用缓存文件: {new_output_path}")
451
+ return new_output_path
452
+
453
+ cmd[-1] = new_output_path.as_posix() # 更新输出文件名为带签名的文件
454
+ else:
455
+ new_output_path = output_path
456
+ logger.info(f"🎬 执行命令: {' '.join(cmd)}")
457
+ subprocess_run_cache(cmd, files, stream_terminal=stream_terminal)
458
+ logger.info(f"✅ 成功生成: {new_output_path}")
459
+ return new_output_path
460
+
461
+
462
+ # =============================
463
+ # 主函数
464
+ # =============================
465
+ def work(config: ScriptConfig) -> None:
466
+ project = config.project
467
+ logger.info(pformat(project))
468
+
469
+ logger.info(f"🎬 开始处理项目: {config.MyGICA_path}")
470
+ segment_files = []
471
+
472
+ # =============================
473
+ # 🎬 正常剪辑片段
474
+ # =============================
475
+ for i, rng in enumerate(project.ranges):
476
+ seg_file = config.cache_dir / f"seg_{rng.start}.mp4"
477
+ new_seg_file = work_clips(config, rng, seg_file)
478
+ segment_files.append(new_seg_file)
479
+
480
+ # =============================
481
+ # 拼接所有片段
482
+ # =============================
483
+ no_bgm = config.cache_dir / f"no_bgm.mp4"
484
+ no_bgm = cat_video_copy(no_bgm, segment_files, config)
485
+
486
+ # =============================
487
+ # 拼接完成后添加背景音乐 / 在片段中添加背景音乐跳过此处
488
+ # =============================
489
+ output = config.cache_dir / f"output.mp4"
490
+ new_output = add_bgm(Path(project.sources['bgm']), frame_to_time(project.start, project.fps), no_bgm, output)
491
+
492
+ # 硬链接到最终输出文件
493
+ config.output.unlink(missing_ok=True)
494
+ os.link(new_output, config.output)
495
+
496
+ # 生成与视频同名的外挂字幕,记录每个画面的选取理由,方便实时检查
497
+ write_reason_subtitle(config)
498
+
499
+ # 成片帧数必须与配置声明的长度一致,这是唯一能拦住滤镜或换算出错的闸门
500
+ expect_frames = project.end - project.start
501
+ check_video(config.output, expect_frames, project.fps, "成片")
502
+ logger.info(f"✅ 成片帧数与帧率校验通过:{expect_frames} 帧 @ {project.fps}")
503
+
504
+ logger.info(f"\n\n\n🎉🎉🎉 全部处理完成!输出文件: {config.output} 🎉🎉🎉\n\n")
505
+
506
+
507
+ def work_clips(config: ScriptConfig, rng: Range, seg_file: Path) -> Path:
508
+ # 提前生成字幕缓存
509
+ pool = ThreadPoolExecutor()
510
+ future_text = None
511
+ futures_clip: list[tuple] = []
512
+ # 构建字幕,Text 自带 start/end 时会在 get_fade_text 里按时段切片
513
+ texts = rng.texts
514
+ if texts:
515
+ new_seg_file_txt = seg_file.with_stem(seg_file.stem + '_text')
516
+ input_list = new_seg_file_txt.with_suffix('.txt')
517
+ future_text = pool.submit(get_fade_text, texts, input_list, config, rng.end - rng.start)
518
+
519
+ segment_files = []
520
+ now_time = rng.start
521
+ for i, clip in enumerate(rng.clips):
522
+ src_path = config.project.sources.get(clip.source)
523
+ # project_start_time = frame_to_time(now_time, config.project.fps)
524
+ # bgm = config.project.sources['bgm']
525
+ frame_count = clip.end - clip.start # 精确帧数
526
+
527
+ clip_file = seg_file.with_stem(seg_file.stem + f'_{i}') if len(rng.clips) > 1 else seg_file
528
+
529
+ af = volume_filter(clip.volume)
530
+ # af_inline = f'volume={clip.volume}dB' if clip.volume is not None else ''
531
+ # af_in = f'[0:a]{af_inline}[a0_vol];[a0_vol]' if clip.volume is not None else '[0:a]'
532
+
533
+ # black 且用户没有准备素材时直接用 lavfi 合成,不必依赖 cache_in 里的占位视频
534
+ if clip.source == 'black' and clip.source not in config.project.sources:
535
+ cmd = [
536
+ 'ffmpeg', '-y', '-hide_banner',
537
+ '-f', 'lavfi', '-i', f'color=c=black:s={config.video_width}x{config.video_height}:r={config.project.fps}',
538
+ '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
539
+ '-vframes', str(frame_count),
540
+ '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2',
541
+ ]
542
+ if clip.filters:
543
+ cmd.extend(['-vf', clip.filters])
544
+ cmd.extend(af + config.video_preset + [clip_file.as_posix()])
545
+ files = []
546
+ # 判断 source 是否是图片
547
+ elif is_image(Path(src_path)):
548
+ # 基础滤镜:缩放和填充
549
+ base_filter = f'scale={config.video_width}:{config.video_height}:force_original_aspect_ratio=decrease,pad={config.video_width}:{config.video_height}:(ow-iw)/2:(oh-ih)/2'
550
+ if clip.filters:
551
+ base_filter = f"{base_filter},{clip.filters}"
552
+
553
+ # 图片 -> 视频:循环 + 精确帧数控制
554
+ cmd = \
555
+ [
556
+ 'ffmpeg', '-y', '-hide_banner',
557
+ '-f', 'lavfi', # 使用 lavfi 生成静音
558
+ '-i', 'anullsrc',
559
+ '-t', str(frame_to_time(frame_count, config.project.fps)), # 设置音频时长与视频匹配
560
+ '-loop', '1',
561
+ '-i', str(src_path),
562
+ '-vframes', str(frame_count), # 精确控制帧数
563
+ '-r', str(config.project.fps), # 设置帧率
564
+ '-vf', base_filter, # 合并所有滤镜
565
+ '-pix_fmt', 'yuv420p10le',
566
+ ] + config.video_preset + [str(clip_file)]
567
+ files = [Path(src_path)]
568
+ else:
569
+ # 正常视频处理(保持原来的精确帧数控制)
570
+ start_time = frame_to_timestamp(clip.start, config.project.fps)
571
+ if clip.sound:
572
+ sound_path = config.project.sources[clip.sound]
573
+ # 如果在片段中替换音频
574
+ cmd = [
575
+ 'ffmpeg', '-y', '-hide_banner',
576
+ '-ss', start_time,
577
+ '-i', src_path,
578
+ '-i', sound_path, # 替换音频
579
+ '-map', '0:v',
580
+ '-map', '1:a',
581
+ ]
582
+ files = [Path(src_path), Path(sound_path)]
583
+ else:
584
+ cmd = [
585
+ 'ffmpeg', '-y', '-hide_banner',
586
+ '-ss', start_time,
587
+ '-i', src_path,
588
+ ]
589
+ files = [Path(src_path)]
590
+ cmd.extend([
591
+ '-vframes', str(frame_count), # 使用精确帧数
592
+ '-c:a', 'aac',
593
+ '-b:a', '128k',
594
+ '-ar', '44100', # 统一采样率
595
+ '-ac', '2', # 统一声道数
596
+ ])
597
+ if clip.filters:
598
+ cmd.extend(['-vf', clip.filters])
599
+ cmd.extend(af + config.video_preset + [clip_file.as_posix()])
600
+
601
+ logger.info(f"✂️ 剪辑: {clip.source} [{clip.start}:{clip.end}] ({frame_count} 帧) → {clip_file.name}")
602
+ # new_clip_file = cache_clip(cmd, files)
603
+ future = pool.submit(cache_clip, cmd, files)
604
+ futures_clip.append((future, frame_count, clip_file, clip_label(rng.start, i, clip)))
605
+
606
+ now_time += frame_count
607
+
608
+ # 每个片段渲染出来的帧数必须与声明一致,改帧数的 filters 会在这里现形
609
+ for future, expect_frames, clip_file, label in futures_clip:
610
+ new_clip_file = future.result()
611
+ check_video(new_clip_file, expect_frames, config.project.fps, f"片段 {clip_file.name}")
612
+ export_clip_frames(new_clip_file, config, expect_frames, label)
613
+ segment_files.append(new_clip_file)
614
+
615
+ if len(rng.clips) > 1:
616
+ new_seg_file = cat_video(seg_file, segment_files, config, config.video_preset_cat, stream_terminal=False)
617
+ check_video(new_seg_file, rng.end - rng.start, config.project.fps, f"拼接后的 {seg_file.name}")
618
+ else:
619
+ new_seg_file = segment_files[0]
620
+
621
+ # 添加字幕
622
+ if future_text is not None:
623
+ pattern, text_files = future_text.result()
624
+ pool.shutdown()
625
+ new_seg_file_txt = seg_file.with_stem(seg_file.stem + '_text')
626
+ cmd = \
627
+ [
628
+ 'ffmpeg', '-y', '-hide_banner',
629
+ '-i', new_seg_file.as_posix(),
630
+ '-framerate', config.project.fps, # 匹配视频帧率
631
+ '-i', pattern, # image2 可以,但 concat 不行
632
+ '-filter_complex', "[0:v][1:v]overlay=0:0",
633
+ ] + config.video_preset + [
634
+ new_seg_file_txt.as_posix()
635
+ ]
636
+ files = [new_seg_file, *text_files]
637
+ new_seg_file_txt = cache_clip(cmd, files)
638
+ return align_duration(new_seg_file_txt, config)
639
+
640
+ pool.shutdown()
641
+ return align_duration(new_seg_file, config)
642
+
643
+
644
+ def ensure_transparent(config: ScriptConfig) -> Path:
645
+ """全透明底图,多个字幕段共用同一张"""
646
+ transparent_path = config.cache_dir / Path("transparent.png")
647
+ if not transparent_path.exists():
648
+ transparent = np.zeros((config.video_height, config.video_width, 4), dtype=np.uint8)
649
+ Image.fromarray(transparent).save(transparent_path)
650
+ return transparent_path
651
+
652
+
653
+ FADE_FRAMES = 10 # 淡入淡出各占多少帧,字幕显示得不够长时按显示长度的一半压
654
+
655
+
656
+ @dataclass
657
+ class TextLayer:
658
+ """一条 Text 单独渲染出来的一层,连同它自己那份淡入淡出序列
659
+
660
+ 淡入淡出只按这条 Text 自己的显示区间算,所以同屏的别的字幕进出不会连累它,
661
+ 一句连续显示的字幕不会因为中间插进另一句就在原处闪一下。
662
+ """
663
+ label: str
664
+ start: int # 相对 Range 起点的显示起点
665
+ end: int # 开区间
666
+ base: Path
667
+ names: list[list[Path]] # 淡入 10 张、淡出 10 张
668
+
669
+ @property
670
+ def span(self) -> int:
671
+ return self.end - self.start
672
+
673
+ @property
674
+ def fade(self) -> int:
675
+ return min(FADE_FRAMES, self.span // 2)
676
+
677
+ def at(self, frame: int) -> Path:
678
+ """按这条 Text 自己的进度挑该用哪张图"""
679
+ offset = frame - self.start
680
+ fade = self.fade
681
+ if fade:
682
+ if offset < fade:
683
+ return self.names[0][offset]
684
+ if offset >= self.span - fade:
685
+ return self.names[1][self.span - 1 - offset]
686
+ return self.base
687
+
688
+
689
+ def text_spans(texts: list[Text], length: int) -> list[tuple[int, int, Text]]:
690
+ """把每条 Text 的显示区间补齐成绝对帧号,缺一端就按 Range 的两端补"""
691
+ return [
692
+ (0 if text.start is None else text.start, length if text.end is None else text.end, text)
693
+ for text in texts
694
+ ]
695
+
696
+
697
+ def build_layer_filter(text: Text, project: ProjectConfig, fontfile: Path) -> str:
698
+ """一层的滤镜串:drawtext 打底,Text.filters 接在它后面只作用在这一条字幕上"""
699
+ chain = build_drawtext_filters([text], project, fontfile=fontfile)
700
+ if text.filters:
701
+ chain = f'{chain},{text.filters}'
702
+ return chain
703
+
704
+
705
+ def check_layer_size(image: Image.Image, config: ScriptConfig, label: str) -> None:
706
+ """图层尺寸必须和成片一致,Text.filters 里混进缩放会让叠加错位"""
707
+ check(
708
+ image.size == (config.video_width, config.video_height),
709
+ f"Text 的 filters 把图层尺寸改成了 {image.size},叠加会错位:{label}",
710
+ )
711
+
712
+
713
+ def render_text_layer(index: int, start: int, end: int, text: Text, transparent_path: Path, config: ScriptConfig) -> TextLayer:
714
+ """把一条 Text 单独渲到透明底上,作为合成用的一层"""
715
+ layer_filter = build_layer_filter(text, config.project, config.fontfile)
716
+ target = config.cache_dir / f'layer_{index}.png'
717
+ cmd = [
718
+ "ffmpeg", "-y", "-hide_banner",
719
+ "-i", transparent_path.as_posix(),
720
+ "-vf", layer_filter,
721
+ '-frames:v', '1',
722
+ '-update', '1',
723
+ target.as_posix(),
724
+ ]
725
+ base = cache_clip(cmd, [transparent_path], stream_terminal=False)
726
+ with Image.open(base) as image:
727
+ check_layer_size(image, config, text.text)
728
+ layer = TextLayer(label=text.text, start=start, end=end, base=base, names=get_blur(base))
729
+ if layer.fade < FADE_FRAMES:
730
+ logger.warning(f'字幕「{text.text}」只显示 {layer.span} 帧,淡入淡出压缩到 {layer.fade} 帧')
731
+ return layer
732
+
733
+
734
+ class LayerComposer:
735
+ """同屏多层字幕的合成器,按层内容命名,内容变了不会复用旧图"""
736
+ def __init__(self, output_list: Path):
737
+ self.output_list = output_list
738
+ self.done: dict[tuple[str, ...], Path] = {}
739
+
740
+ def compose(self, paths: list[Path]) -> Path:
741
+ key = tuple(path.name for path in paths)
742
+ hit = self.done.get(key)
743
+ if hit is not None:
744
+ return hit
745
+ digest = hashlib.md5('|'.join(key).encode()).hexdigest()[:6]
746
+ target = self.output_list.with_stem(f'{self.output_list.stem}_mix_{digest}').with_suffix('.png')
747
+ if not target.exists():
748
+ image = Image.open(paths[0]).convert('RGBA')
749
+ for path in paths[1:]:
750
+ image = Image.alpha_composite(image, Image.open(path).convert('RGBA'))
751
+ image.save(target)
752
+ self.done[key] = target
753
+ return target
754
+
755
+
756
+ def link_frame(source: Path, target: Path) -> None:
757
+ """用硬链接把某一帧指向已生成的图,避免复制像素"""
758
+ target.unlink(missing_ok=True)
759
+ os.link(source, target)
760
+
761
+
762
+ def get_fade_text(texts: list[Text], output_list: Path, config: ScriptConfig, length: int) -> tuple[str, list[Path]]:
763
+ """生成整段字幕的逐帧 PNG 序列,每条 Text 各渲一层,各自淡入淡出"""
764
+ transparent_path = ensure_transparent(config)
765
+ spans = text_spans(texts, length)
766
+ # 缓存键取配置本身,不必先把图层渲出来才知道内容变没变
767
+ digest = hashlib.md5('|'.join(f'{t0}:{t1}:{text!r}' for t0, t1, text in spans).encode()).hexdigest()[:6]
768
+ file_name = output_list.with_stem(f'{output_list.stem}_{digest}_%04d').with_suffix('.png').as_posix()
769
+
770
+ # 使用缓存:首尾两帧都在才认为序列完整,避免读到中断留下的半套
771
+ if Path(file_name % 0).exists() and Path(file_name % (length - 1)).exists():
772
+ logger.info("⏭️ 使用缓存的淡入淡出字幕图片序列")
773
+ return file_name, [Path(file_name % i) for i in range(length)]
774
+
775
+ layers = [
776
+ render_text_layer(index, t0, t1, text, transparent_path, config)
777
+ for index, (t0, t1, text) in enumerate(spans)
778
+ ]
779
+ composer = LayerComposer(output_list)
780
+
781
+ for i in range(length):
782
+ active = [layer for layer in layers if layer.start <= i < layer.end]
783
+ if not active:
784
+ link_frame(transparent_path, Path(file_name % i))
785
+ elif len(active) == 1:
786
+ link_frame(active[0].at(i), Path(file_name % i))
787
+ else:
788
+ link_frame(composer.compose([layer.at(i) for layer in active]), Path(file_name % i))
789
+
790
+ return file_name, [Path(file_name % i) for i in range(length)]
791
+
792
+
793
+ def get_blur(base_text: Path) -> list[list[Path]]:
794
+ """生成淡入淡出字幕的图片序列,文件名跟着图层内容走,生成过就直接复用"""
795
+ names = [
796
+ [base_text.with_stem(base_text.stem + f'_{i:02d}') for i in range(FADE_FRAMES)],
797
+ [base_text.with_stem(base_text.stem + f'-{i:02d}') for i in range(FADE_FRAMES)],
798
+ ]
799
+ if all(path.exists() for pair in names for path in pair):
800
+ return names
801
+
802
+ img = Image.open(base_text)
803
+ img_np = np.array(img)
804
+
805
+ def rotate(image_np: np.ndarray) -> np.ndarray:
806
+ return image_np[::-1, ::-1, :]
807
+
808
+ for k in range(2):
809
+ if k == 1: img_np = rotate(img_np) # noqa: E701
810
+ alpha_channel = img_np[:, :, 3]
811
+ alpha_channel = np.max(alpha_channel, axis=0)
812
+ painted = np.where(alpha_channel != 0)[0]
813
+ if len(painted) == 0:
814
+ # 空字幕没有像素可淡,两端的图都拿原图顶上
815
+ for i in range(FADE_FRAMES):
816
+ for path in (names[0][i], names[1][i]):
817
+ path.unlink(missing_ok=True)
818
+ os.link(base_text, path)
819
+ return names
820
+ start = int(painted.min())
821
+ end = int(painted.max())
822
+ step = (end - start) // FADE_FRAMES
823
+ for i in range(FADE_FRAMES):
824
+ mask = np.ones_like(alpha_channel).astype(np.double)
825
+ l = start + i * step
826
+ r = start + (i + 1) * step
827
+ mask[r:] = 0
828
+ mask[l:r] *= 1 - np.arange(step) / step
829
+ new_img_np = img_np.copy().astype(np.double)
830
+ new_img_np[:, :, 3] *= mask[np.newaxis, :]
831
+ if k == 0:
832
+ new_img = Image.fromarray(new_img_np.astype(np.uint8))
833
+ else:
834
+ new_img = Image.fromarray(rotate(new_img_np).astype(np.uint8))
835
+ new_img.save(names[k][i])
836
+
837
+ return names
838
+
839
+
840
+ SILENT_DB = -80 # 到这个档位以下的音量一律按静音处理,直接归零
841
+
842
+
843
+ def volume_filter(volume: Optional[float]) -> list[str]:
844
+ """把音量值转成 ffmpeg 参数
845
+
846
+ volume 是相对衰减,原始越响残留越高,光靠减够不了零;所以到静音档就直接把振幅写成 0。
847
+ """
848
+ if volume is None:
849
+ return []
850
+ if volume <= SILENT_DB:
851
+ return ['-af', 'volume=0']
852
+ return ['-af', f'volume={volume}dB']
853
+
854
+
855
+ def cat_video(output: Path, segment_files: list[Path], config: ScriptConfig, param: list[str], stream_terminal: bool = True) -> Path:
856
+ """用 concat 滤镜拼接,并把时间戳归零
857
+
858
+ 早先用 concat 分离器配 -c:v copy,因为各段音频对齐的差异,每个接缝处会留下约一帧的空档,
859
+ 成片在那些位置会定格一帧。改成滤镜拼接,由 ffmpeg 重新生成连续时间戳。
860
+ """
861
+ logger.info(f"🎥 拼接 {len(segment_files)} 个片段 → {output}")
862
+ inputs: list[str] = []
863
+ for seg in segment_files:
864
+ inputs += ['-i', seg.as_posix()]
865
+ count = len(segment_files)
866
+ pairs = ''.join(f'[{i}:v][{i}:a]' for i in range(count))
867
+ # 按帧序号重写时间戳,段内音频与视频长度不完全一致时会带出漂移,靠这一步拉平
868
+ graph = (
869
+ f"{pairs}concat=n={count}:v=1:a=1[cv][ca];"
870
+ "[cv]setpts=N/FRAME_RATE/TB[v];[ca]asetpts=N/SR/TB[a]"
871
+ )
872
+ cmd = \
873
+ [
874
+ 'ffmpeg', '-y', '-hide_banner',
875
+ ] + inputs + [
876
+ '-filter_complex', graph,
877
+ '-map', '[v]', '-map', '[a]',
878
+ ] + param + [
879
+ output.as_posix()
880
+ ]
881
+ logger.info(cmd)
882
+ return cache_clip(cmd, segment_files, stream_terminal=stream_terminal)
883
+
884
+
885
+ def align_duration(path: Path, config: ScriptConfig) -> Path:
886
+ """把整段的容器时长对齐到视频的精确时长
887
+
888
+ concat 分离器是按容器时长累加偏移的。段内音频比视频长十几毫秒,下一段就要晚十几毫秒
889
+ 才开始,接缝处于是留下一个不到一帧的空档,成片会在那里定格一下。把音频 pad 或裁到
890
+ 帧数乘上分母再除以分子,时长就精确了,最终拼接才能直接复制流而不再重编码。
891
+ """
892
+ frames = probe_video(path)[0]
893
+ num, denom = parse_fps(config.project.fps)
894
+ exact = Fraction(frames * denom, num)
895
+ target = path.with_stem(path.stem + '_aligned')
896
+ cmd = [
897
+ 'ffmpeg', '-y', '-hide_banner',
898
+ '-i', path.as_posix(),
899
+ '-c:v', 'copy',
900
+ '-af', 'apad',
901
+ '-t', f'{float(exact):.9f}',
902
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2',
903
+ target.as_posix(),
904
+ ]
905
+ return cache_clip(cmd, [path], stream_terminal=False)
906
+
907
+
908
+ def cat_video_copy(output: Path, segment_files: list[Path], config: ScriptConfig, stream_terminal: bool = True) -> Path:
909
+ """用 concat 分离器直接复制流拼接,一帧画面都不用再编
910
+
911
+ 前提是各段的容器时长都对齐过,见 align_duration。有一段没对齐,接缝处就会留下空档,
912
+ 成片校验会当场报出来。
913
+ """
914
+ logger.info(f"🎥 直接复制拼接 {len(segment_files)} 个片段 → {output}")
915
+ content = ''.join(f"file '{seg.as_posix()}'\n" for seg in segment_files)
916
+ # 缓存签名只看命令不看清单内容,把清单摘要写进文件名,换了片段才不会命中旧结果
917
+ digest = hashlib.md5(content.encode()).hexdigest()[:6]
918
+ concat_file = config.cache_dir / f'{output.stem}_concat_{digest}.txt'
919
+ concat_file.write_text(content, encoding='utf-8')
920
+ cmd = [
921
+ 'ffmpeg', '-y', '-hide_banner',
922
+ '-f', 'concat', '-safe', '0',
923
+ '-i', concat_file.as_posix(),
924
+ '-c', 'copy',
925
+ output.as_posix(),
926
+ ]
927
+ return cache_clip(cmd, segment_files, stream_terminal=stream_terminal)
928
+
929
+
930
+ def measure_true_peak(path: Path) -> float:
931
+ """用 loudnorm 测量音频真峰值,只读音频流,返回 dBTP"""
932
+ cmd = [
933
+ 'ffmpeg', '-hide_banner',
934
+ '-i', path.as_posix(),
935
+ '-vn',
936
+ '-af', 'loudnorm=print_format=json',
937
+ '-f', 'null',
938
+ '-'
939
+ ]
940
+ res = subprocess_run(cmd, stream_terminal=False)
941
+ lines: list[str] = [k.strip() for k in res.stderr.splitlines()]
942
+ try:
943
+ start = lines.index('{')
944
+ end = lines.index('}')
945
+ except ValueError as error:
946
+ raise ValueError(f"loudnorm 未输出可解析的 JSON,stderr 末尾:{res.stderr[-500:]}") from error
947
+ j = json.loads('\n'.join(lines[start:end + 1]))
948
+ return float(j['input_tp'])
949
+
950
+
951
+ def add_bgm(bgm: Path, audio_advance_sec: float, input_path: Path, output_path: Path, stream_terminal: bool = True) -> Path:
952
+ """添加背景音乐,先只对混音音频做真峰值闭环,收敛后再一次性合成视频"""
953
+ logger.info(f'添加 bgm 并提前 {audio_advance_sec:.6f} 秒')
954
+
955
+ tmp_output = input_path.parent / output_path.name
956
+ audio_path = tmp_output.with_suffix('.aac')
957
+ cmd = [
958
+ 'ffmpeg', '-y', '-hide_banner',
959
+ '-i', input_path.as_posix(),
960
+ '-i', bgm.as_posix(),
961
+ '-filter_complex',
962
+ # 关键修改:对两个音频流都进行aresample和asetpts,确保它们严格同步
963
+ f'[0:a]aresample=async=1:first_pts=0[a0]' # 处理视频原音频,重置时间戳并异步重采样
964
+ f';[1:a]atrim=start={audio_advance_sec},aresample=async=1[a1]' # 处理背景音乐
965
+ f';[a0][a1]amix=inputs=2:duration=first:dropout_transition=0[a]' # 混合
966
+ ,
967
+ '-map', '[a]',
968
+ '-c:a', 'aac',
969
+ '-b:a', '192k',
970
+ audio_path.as_posix(),
971
+ ]
972
+ current_audio = cache_clip(cmd, [input_path, bgm], stream_terminal=stream_terminal)
973
+
974
+ target_tp = -2.0
975
+ tolerance = 0.1
976
+ max_rounds = 3
977
+ for iteration in range(max_rounds):
978
+ input_tp = measure_true_peak(current_audio)
979
+ if not math.isfinite(input_tp):
980
+ logger.warning('♻️ 混音是纯静音,真峰值无从归一,跳过增益闭环')
981
+ break
982
+ dB = target_tp - input_tp
983
+ if abs(dB) <= tolerance:
984
+ logger.info(f"♻️ 真峰值已达标:input_tp={input_tp:.2f}dBTP,第 {iteration + 1} 轮收敛")
985
+ break
986
+ logger.info(f"♻️ 真峰值归一化第 {iteration + 1} 轮:input_tp={input_tp:.2f}dBTP,增益 {dB:+.2f}dB")
987
+ gain_audio = current_audio.with_name(current_audio.stem + f'_gain{iteration}.aac')
988
+ cmd = [
989
+ 'ffmpeg', '-y', '-hide_banner',
990
+ '-i', current_audio.as_posix(),
991
+ '-af', f'volume={dB}dB',
992
+ '-c:a', 'aac',
993
+ '-b:a', '192k',
994
+ gain_audio.as_posix(),
995
+ ]
996
+ current_audio = cache_clip(cmd, [current_audio], stream_terminal=False)
997
+ else:
998
+ logger.warning(f"♻️ 真峰值归一化 {max_rounds} 轮仍未进入 ±{tolerance}dB 容差,请人工确认输出")
999
+
1000
+ cmd = [
1001
+ 'ffmpeg', '-y', '-hide_banner',
1002
+ '-i', input_path.as_posix(),
1003
+ '-i', current_audio.as_posix(),
1004
+ '-map', '0:v',
1005
+ '-map', '1:a',
1006
+ '-c:v', 'copy',
1007
+ '-c:a', 'copy',
1008
+ tmp_output.as_posix(),
1009
+ ]
1010
+ return cache_clip(cmd, [input_path, current_audio], stream_terminal=stream_terminal)
1011
+
1012
+
1013
+ # =============================
1014
+ # 启动
1015
+ # =============================
1016
+ @click.command()
1017
+ @click.argument('mygica_path', type=click.Path(exists=True, path_type=Path))
1018
+ @click.option('--root', default=None, type=click.Path(path_type=Path), help='项目根目录,相对路径的解析基准,默认取 TOML 所在目录')
1019
+ @click.option('--font-file', default='SC-Heavy.otf', type=click.Path(path_type=Path), help='字体文件路径', show_default=True)
1020
+ @click.option('--cache-dir', default='cache_dir', type=click.Path(path_type=Path), help='缓存文件夹路径', show_default=True)
1021
+ @click.option('--output-dir', default='output_dir', type=click.Path(path_type=Path), help='输出文件夹路径', show_default=True)
1022
+ @click.option('--range', 'range_spec', default=None, help='只渲染指定的 Range,写法 3 或 3-5,1 起数,用于局部预览')
1023
+ @click.option('--min-font-ratio', default=0.03, type=float, help='字号相对屏高的下限,低于就告警', show_default=True)
1024
+ @click.option('--verify/--no-verify', default=True, help='把每个 clip 的首中末帧导出到校验目录', show_default=True)
1025
+ @click.option('--verify-dir', default='verify_dir', type=click.Path(path_type=Path), help='校验帧的落盘目录', show_default=True)
1026
+ def cli(
1027
+ mygica_path: Path,
1028
+ root: Path,
1029
+ cache_dir: Path,
1030
+ output_dir: Path,
1031
+ font_file: Path,
1032
+ range_spec: str,
1033
+ min_font_ratio: float,
1034
+ verify: bool,
1035
+ verify_dir: Path,
1036
+ ) -> None:
1037
+ config = ScriptConfig(
1038
+ MyGICA_path=mygica_path,
1039
+ root=root,
1040
+ cache_dir=cache_dir,
1041
+ output_dir=output_dir,
1042
+ fontfile=font_file,
1043
+ range_spec=range_spec,
1044
+ min_font_ratio=min_font_ratio,
1045
+ verify=verify,
1046
+ verify_dir=verify_dir,
1047
+ )
1048
+ work(config)
1049
+
1050
+
1051
+ if __name__ == '__main__':
1052
+ cli()