feat(screencast): 统一字幕样式 / 修字幕重叠 / 修子进程乱码
- composer.py:抽出 _JIANGCHANG_SUBTITLE_STYLE 常量,FontSize 28→14 / Outline 2→2(保持),1080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感 - subtitle.py:generate_srt 加 post-process 排队,相邻字幕互相让位 (MIN_DURATION=1.0s / GAP=0.1s),任意时刻屏幕上最多 1 条字幕, 避免开头几秒密集触发的字幕叠成两行 - runner.py:subprocess.Popen 注入 PYTHONIOENCODING=utf-8 + PYTHONUTF8=1, 让 Windows 子进程 Python 强制 UTF-8 输出,runner 端 UTF-8 解码即对齐, 修中文乱码 所有 skill 录屏受益,向后兼容(非 Windows 系统 UTF-8 注入无副作用)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,17 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性
|
||||||
|
# FontSize 14:1080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感
|
||||||
|
# Outline 2:14 号字配 Outline 3 会显胖,2 已足够在浅色背景上保持可读
|
||||||
|
# 修改请评估对所有 skill 录屏的影响。
|
||||||
|
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||||
|
"FontName=Arial,FontSize=14,"
|
||||||
|
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||||
|
"Outline=2,Shadow=1,Alignment=2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compose_video(
|
def compose_video(
|
||||||
frames_dir: str,
|
frames_dir: str,
|
||||||
fps: int,
|
fps: int,
|
||||||
@@ -32,9 +43,7 @@ def compose_video(
|
|||||||
|
|
||||||
subtitle_filter = (
|
subtitle_filter = (
|
||||||
f"subtitles='{srt_path_str}'"
|
f"subtitles='{srt_path_str}'"
|
||||||
":force_style='FontName=Arial,FontSize=28,"
|
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||||||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
|
||||||
"Outline=2,Shadow=1,Alignment=2'"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 视频和音频都放进 filter_complex,避免 -vf 与 -filter_complex 混用报错
|
# 视频和音频都放进 filter_complex,避免 -vf 与 -filter_complex 混用报错
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ def run_screencast(
|
|||||||
engine.set_start_time(time.time())
|
engine.set_start_time(time.time())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Windows 子进程 stdout 默认走系统 ANSI code page(中文 Windows 是 cp936/GBK),
|
||||||
|
# 这边用 encoding="utf-8" 解码会乱码。注入 PYTHONIOENCODING + PYTHONUTF8 让
|
||||||
|
# 子进程 Python 强制以 UTF-8 输出,两端编码对齐。非 Windows 默认就 UTF-8,无副作用。
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
env["PYTHONUTF8"] = "1"
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "pytest", *pytest_args],
|
[sys.executable, "-m", "pytest", *pytest_args],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -73,6 +80,7 @@ def run_screencast(
|
|||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
assert proc.stdout is not None
|
assert proc.stdout is not None
|
||||||
for line in proc.stdout:
|
for line in proc.stdout:
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ class SubtitleEngine:
|
|||||||
self._matched.add(keyword)
|
self._matched.add(keyword)
|
||||||
|
|
||||||
def generate_srt(self, output_path: str) -> None:
|
def generate_srt(self, output_path: str) -> None:
|
||||||
|
"""生成 SRT 文件,自动处理字幕重叠。
|
||||||
|
|
||||||
|
字幕重叠修复策略(避免屏幕上同时显示 2+ 条叠成两行):
|
||||||
|
- 字幕按 start 时间升序排序后;
|
||||||
|
- 相邻字幕保留 GAP=0.1s 间隔;
|
||||||
|
- 每条字幕保证至少 MIN_DURATION=1.0s 显示时间(短到这个值仍读不完是字幕脚本设计问题);
|
||||||
|
- 若当前条让位给下一条后剩余时间 < MIN_DURATION,则把下一条 start 往后推。
|
||||||
|
这样任意时刻屏幕上最多 1 条字幕。
|
||||||
|
"""
|
||||||
def _fmt(s: float) -> str:
|
def _fmt(s: float) -> str:
|
||||||
h = int(s // 3600)
|
h = int(s // 3600)
|
||||||
m = int((s % 3600) // 60)
|
m = int((s % 3600) // 60)
|
||||||
@@ -47,9 +56,26 @@ class SubtitleEngine:
|
|||||||
ms = int((s % 1) * 1000)
|
ms = int((s % 1) * 1000)
|
||||||
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
|
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
|
||||||
|
|
||||||
|
# 防重叠 post-process
|
||||||
|
MIN_DURATION = 1.0
|
||||||
|
GAP = 0.1
|
||||||
|
entries = sorted(self._entries, key=lambda e: e.start)
|
||||||
|
for i in range(len(entries) - 1):
|
||||||
|
cur, nxt = entries[i], entries[i + 1]
|
||||||
|
max_end = nxt.start - GAP
|
||||||
|
new_dur = max_end - cur.start
|
||||||
|
if new_dur < MIN_DURATION:
|
||||||
|
# 太挤:cur 至少撑 MIN_DURATION 秒,把 nxt 推迟
|
||||||
|
cur.duration = MIN_DURATION
|
||||||
|
nxt.start = cur.start + MIN_DURATION + GAP
|
||||||
|
else:
|
||||||
|
# 正常让位:cur 显示到 (nxt.start - GAP)
|
||||||
|
cur.duration = min(cur.duration, new_dur)
|
||||||
|
# 最后一条不需要让位,保持原 duration
|
||||||
|
|
||||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
for i, e in enumerate(self._entries, 1):
|
for i, e in enumerate(entries, 1):
|
||||||
f.write(f"{i}\n")
|
f.write(f"{i}\n")
|
||||||
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
|
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
|
||||||
f.write(f"{e.text}\n\n")
|
f.write(f"{e.text}\n\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user