- 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>
82 lines
3.1 KiB
Python
82 lines
3.1 KiB
Python
"""字幕引擎:根据 stdout 关键词打时间戳,生成 SRT 字幕文件。"""
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import List, Tuple
|
||
|
||
|
||
@dataclass
|
||
class _Entry:
|
||
start: float # 距录制开始的秒数
|
||
text: str
|
||
duration: float = 5.0 # 每条字幕默认显示 5 秒
|
||
|
||
|
||
class SubtitleEngine:
|
||
def __init__(self, script: List[Tuple[str, str]], default_duration: float = 5.0):
|
||
"""
|
||
script: [(关键词, 字幕文案), ...]
|
||
关键词大小写不敏感,每个关键词只触发一次。
|
||
"""
|
||
self._script = script
|
||
self._default_duration = default_duration
|
||
self._entries: List[_Entry] = []
|
||
self._matched: set[str] = set()
|
||
self._t0: float = 0.0
|
||
|
||
def set_start_time(self, t: float) -> None:
|
||
self._t0 = t
|
||
|
||
def process_line(self, line: str) -> None:
|
||
lower = line.lower()
|
||
for keyword, text in self._script:
|
||
if keyword in self._matched:
|
||
continue
|
||
if keyword.lower() in lower:
|
||
elapsed = time.time() - self._t0
|
||
self._entries.append(_Entry(start=elapsed, text=text, duration=self._default_duration))
|
||
self._matched.add(keyword)
|
||
|
||
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:
|
||
h = int(s // 3600)
|
||
m = int((s % 3600) // 60)
|
||
sec = int(s % 60)
|
||
ms = int((s % 1) * 1000)
|
||
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)
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
for i, e in enumerate(entries, 1):
|
||
f.write(f"{i}\n")
|
||
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
|
||
f.write(f"{e.text}\n\n")
|