vibe-coding-课程-干货集训
访谈视频自动切片
生成示例
| 视频 | 封面图 |
|---|---|
![]() |
Skills文件夹结构
youtube-slicer/├── SKILL.md # Skill 入口定义├── scripts/│ ├── config.py # 共享配置(API keys、字体、字幕样式)│ ├── download.py # Step 1: 下载视频+字幕+heatmap│ ├── detect_highlights.py # Step 2: 高光检测(heatmap+LLM)│ ├── pipeline.py # Step 3: 切片+字幕完整流水线│ └── cover.py # Step 4: 封面图生成(即梦AI+ffmpeg)├── assets/│ └── silhouettes/│ ├── mapping.json # 人物名→照片文件名映射│ └── *.png # 人物透明背景照片└── references/ └── cover-design.md # 封面设计规范
Skills文件具体内容
SKILL.md
---name: youtube-slicerdescription: | YouTube 长视频自动切片 Pipeline:从 YouTube URL 到带中文字幕的高光切片视频 + AI封面图。 完整流程:下载视频+字幕+heatmap → 高光检测(heatmap热区+LLM定位) → 切片+中文字幕(Whisper转录+LLM翻译+ffmpeg烧录) → 封面图(即梦AI背景+人物合成+标题)。 触发词:切片、slice、高光、highlight、字幕、subtitle、YouTube切片、跑pipeline、做视频切片、封面、coverread_when: - 用户提供 YouTube URL 要求生成切片 - 需要从长视频中提取高光片段 - 需要给视频添加中文字幕 - 需要生成视频封面图 - 调试或重跑 pipeline 中的某个步骤metadata: openclaw: emoji: "✂️" requires: env: - GROQ_API_KEY - MINIMAX_API_KEY - ARK_API_KEY bins: - ffmpeg - yt-dlp - python3 - curl---# YouTube Slicer Pipeline从 YouTube 长视频中自动提取高光片段,生成带中文字幕的切片视频 + 封面图。目标平台:抖音/小红书。## 环境变量export GROQ_API_KEY="..." # Groq Whisper APIexport MINIMAX_API_KEY="..." # MiniMax M2.7 (翻译+高光分析)export ARK_API_KEY="..." # 火山引擎即梦API (封面背景生图)export SUBTITLE_FONT="..." # 可选,默认 ~/podcastclip/assets/fonts/SourceHanSansCN-Regular.otf## 完整工作流所有脚本在 `scripts/` 目录下。**必须在工作目录下运行**。### Step 1: 下载cd <工作目录>python3 <skill-path>/scripts/download.py <YouTube-URL> -o .产出:`video.mp4`、`subtitle.en.srt`、`heatmap.json`自动检测本地代理。`--skip-video` 只下字幕和 heatmap。### Step 2: 高光检测python3 <skill-path>/scripts/detect_highlights.py \ --heatmap heatmap.json --srt subtitle.en.srt \ --output highlights.json --top-n 8 --threshold 0.3产出:`highlights.json`工作原理:heatmap 热区 → ±2分钟英文字幕上下文 → MiniMax M2.7 定位完整思想弧 → 去重### Step 3: Pipeline(切片+字幕)python3 <skill-path>/scripts/pipeline.py \ --video video.mp4 --highlights highlights.json \ --output output --top-n 5产出:`output/clip_*.mp4`(带中文字幕成品)。`--only 3` 单独处理某个。### Step 4: 封面图生成python3 <skill-path>/scripts/cover.py \ --guest "Ilya Sutskever" \ --title "Ilya最新访谈" \ --hook "真正决定大模型进化的是品味" \ --output cover.png产出:`cover.png`(1080x1440, 3:4 竖版封面)也可手动指定照片和背景:python3 cover.py --image face.png --title "标题" --hook "金句" --output cover.pngpython3 cover.py --image face.png --bg-image bg.png --title "标题" --output cover.png # 跳过AI生图**封面生成流程(3步):**| 步骤 | 做什么 | 说明 ||------|--------|------|| 1. AI生成背景 | 即梦API文生图 | 只生背景(深色科技风+神经网络节点),不含人物 || 2. 合成人物 | ffmpeg overlay | 人物照片按固定布局合成到背景上 || 3. 叠加标题 | ffmpeg drawtext | Alibaba Health Font 2.0 CN 粗体 |**封面设计规范(必须遵守):**详见 references/cover-design.md。核心要点:- 人物高度占画面 60%,底部对齐下移 5%- 标题 128px 在顶部 8%,金句 88px 紧跟其下- 不能有大块空白,文字和人物要有叠压感- AI 只生背景,人物布局由 ffmpeg 控制- **生成后必须自检**:人物大小、位置、空白比例、文字可读性**人物照片管理:**`assets/silhouettes/` 目录存放人物透明背景 PNG,`mapping.json` 维护名字→文件映射。## 技术约束1. **字幕用 ffmpeg drawtext** — ASS 在 macOS 上中文乱码2. **切片用 input seeking** — `-ss` 在 `-i` 前 + `setpts=N/FR/TB` 防黑帧3. **断句是确定性的** — 按标点切,LLM 只翻译不碰原文4. **翻译 prompt** — 公司名/人名保留英文,填充词删除,≤20 汉字/句5. **封面 AI 只管背景** — 人物比例和位置必须由 ffmpeg 精确控制## 输出结构output/├── raw/ 无字幕原始切片├── cache/ 每个 clip 的分句 JSON (start, end, en, zh)├── clip_*.mp4 成品 (1920x1080, H.264, 带中文字幕)└── cover.png 封面图 (1080x1440)```---
Scripts
- config.py
```python"""YouTube Slicer 共享配置 — API keys 从环境变量读取"""import osimport sys# ── API Keys ──────────────────────────────────────────GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "")MINIMAX_URL = "https://api.minimaxi.com/anthropic/v1/messages"GROQ_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions"LLM_MODEL = "MiniMax-M2.7"# ── 字体 ──────────────────────────────────────────────FONT_PATH = os.environ.get( "SUBTITLE_FONT", os.path.expanduser("<YOUR_FONT_PATH>"), # ⚠️ 填入 Source Han Sans CN 字体路径)# ── 字幕样式 ──────────────────────────────────────────CN_FONTSIZE = 46CN_COLOR = "white"SUB_MARGIN_BOTTOM = 80MAX_WORDS_PER_SEGMENT = 18PADDING = 20.0 # 前后各扩展 20s 用于找句子边界def check_env(): """启动时检查必要的环境变量和工具""" missing = [] if not GROQ_API_KEY: missing.append("GROQ_API_KEY") if not MINIMAX_API_KEY: missing.append("MINIMAX_API_KEY") if missing: print(f"❌ 缺少环境变量: {', '.join(missing)}") sys.exit(1) if not os.path.isfile(FONT_PATH): print(f"⚠️ 字体文件不存在: {FONT_PATH}") print(" 设置 SUBTITLE_FONT 环境变量指向 Source Han Sans CN 字体")```---
- download.py
```python#!/usr/bin/env python3"""Step 1: 下载 YouTube 视频 + 英文字幕 + heatmap"""import osimport reimport jsonimport socketimport subprocessimport argparsefrom pathlib import PathCOMMON_PROXY_PORTS = [1087, 7890, 10809, 6152, 8080]def detect_proxy(): env_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("https_proxy") if env_proxy: print(f"✓ 代理(环境变量): {env_proxy}") return env_proxy for port in COMMON_PROXY_PORTS: try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1) if sock.connect_ex(("127.0.0.1", port)) == 0: url = f"http://127.0.0.1:{port}" r = subprocess.run( ["curl", "-s", "--connect-timeout", "3", "--proxy", url, "-I", "https://youtube.com"], capture_output=True, ) if r.returncode == 0: print(f"✓ 代理(自动检测): {url}") return url sock.close() except Exception: continue print("⚠️ 未检测到代理") return Nonedef run_ytdlp(args, proxy=None): cmd = ["yt-dlp"] if proxy: cmd += ["--proxy", proxy] cmd += args return subprocess.run(cmd, capture_output=True, text=True)def download_video(url, output_dir, proxy): tpl = str(Path(output_dir) / "video") r = run_ytdlp(["-o", tpl, "--format", "bestvideo[height<=1080]+bestaudio/best", "--merge-output-format", "mp4", url], proxy) if r.returncode != 0: raise RuntimeError(f"视频下载失败: {r.stderr[:300]}") mp4s = list(Path(output_dir).glob("video*.mp4")) if not mp4s: raise RuntimeError("未找到下载的视频文件") latest = max(mp4s, key=lambda p: p.stat().st_mtime) print(f"✓ 视频: {latest}") return latestdef download_subtitle(url, output_dir, proxy, lang="en"): tpl = str(Path(output_dir) / f"subtitle.{lang}") # json3 格式(词级时间戳,用于 heatmap 场景) run_ytdlp(["--write-auto-sub", "--sub-lang", lang, "--sub-format", "json3", "--skip-download", "--no-check-formats", "-o", tpl, url], proxy) # srt 格式 run_ytdlp(["--write-auto-sub", "--sub-lang", lang, "--convert-subs", "srt", "--skip-download", "-o", tpl, url], proxy) srts = list(Path(output_dir).glob(f"subtitle.{lang}*.srt")) if srts: latest = max(srts, key=lambda p: p.stat().st_mtime) print(f"✓ 字幕(SRT): {latest}") return latest print(f"⚠️ 未下载到 {lang} 字幕") return Nonedef download_heatmap(url, output_dir, proxy): """通过 yt-dlp -j 获取 heatmap 数据""" r = run_ytdlp(["-j", url], proxy) if r.returncode != 0: print("⚠️ 无法获取视频元数据") return None try: info = json.loads(r.stdout) heatmap = info.get("heatmap") if heatmap: out = Path(output_dir) / "heatmap.json" out.write_text(json.dumps(heatmap, indent=2), encoding="utf-8") print(f"✓ Heatmap: {out} ({len(heatmap)} 数据点)") return out else: print("⚠️ 该视频没有 heatmap 数据") return None except (json.JSONDecodeError, KeyError): print("⚠️ Heatmap 解析失败") return Nonedef main(): parser = argparse.ArgumentParser(description="YouTube 下载: 视频 + 字幕 + heatmap") parser.add_argument("url", help="YouTube URL") parser.add_argument("-o", "--output", default=".", help="输出目录") parser.add_argument("-p", "--proxy", help="代理地址") parser.add_argument("--skip-video", action="store_true", help="只下载字幕和heatmap") args = parser.parse_args() Path(args.output).mkdir(parents=True, exist_ok=True) proxy = args.proxy or detect_proxy() print(f"\n{'='*50}") print(f"YouTube Slicer — Step 1: 下载") print(f"{'='*50}\n") # 1. 英文字幕 srt = download_subtitle(args.url, args.output, proxy, "en") # 2. Heatmap hm = download_heatmap(args.url, args.output, proxy) # 3. 视频 if not args.skip_video: vid = download_video(args.url, args.output, proxy) print(f"\n{'='*50}") print("下载完成") print(f"{'='*50}")if __name__ == "__main__": main()```---
- detect_highlights.py
```python#!/usr/bin/env python3"""Step 2: 高光检测YouTube Heatmap 热区 + 英文字幕上下文 + LLM 精确边界 → highlights.json"""import jsonimport reimport subprocessimport timeimport argparsefrom pathlib import Pathfrom dataclasses import dataclassfrom typing import Listfrom config import MINIMAX_URL, MINIMAX_API_KEY, LLM_MODEL, check_env@dataclassclass SubEntry: start: float end: float text: str@dataclassclass HotZone: start: float end: float peak_value: float avg_value: float@dataclassclass Highlight: start: float end: float score: float reason_zh: str hook: str text: str = ""# ── 解析 ──────────────────────────────────────────────def parse_srt(path: str) -> List[SubEntry]: content = Path(path).read_text(encoding="utf-8") content = re.sub(r"<[^>]+>", "", content) pattern = r"(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n(.*?)(?=\n\n|\Z)" entries = [] for m in re.findall(pattern, content, re.DOTALL): s = int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 e = int(m[5]) * 3600 + int(m[6]) * 60 + int(m[7]) + int(m[8]) / 1000 text = m[9].replace("\n", " ").strip() if text: entries.append(SubEntry(start=s, end=e, text=text)) return entries# ── Stage 1: Heatmap → 热区 ──────────────────────────def find_hot_zones(heatmap: list, threshold: float = 0.3, min_duration: float = 30.0) -> List[HotZone]: hot = [h for h in heatmap if h["value"] >= threshold] if not hot: return [] seg_dur = heatmap[1]["end_time"] - heatmap[1]["start_time"] if len(heatmap) > 1 else 60 zones = [] cs, ce, cp, cv = hot[0]["start_time"], hot[0]["end_time"], hot[0]["value"], [hot[0]["value"]] for h in hot[1:]: if h["start_time"] - ce <= seg_dur * 2: ce = h["end_time"] cp = max(cp, h["value"]) cv.append(h["value"]) else: if ce - cs >= min_duration: zones.append(HotZone(cs, ce, cp, sum(cv) / len(cv))) cs, ce, cp, cv = h["start_time"], h["end_time"], h["value"], [h["value"]] if ce - cs >= min_duration: zones.append(HotZone(cs, ce, cp, sum(cv) / len(cv))) zones.sort(key=lambda z: z.peak_value, reverse=True) return zones# ── Stage 2: LLM 精确定位 ────────────────────────────def call_llm(prompt: str, max_tokens: int = 4000) -> str: data = {"model": LLM_MODEL, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]} r = subprocess.run( ["curl", "-s", MINIMAX_URL, "-H", f"Authorization: Bearer {MINIMAX_API_KEY}", "-H", "Content-Type: application/json", "-H", "anthropic-version: 2023-06-01", "-d", json.dumps(data)], capture_output=True, text=True, timeout=300, ) resp = json.loads(r.stdout) for item in resp.get("content", []): if item.get("type") == "text": return item["text"] return ""def extract_context(entries: List[SubEntry], zone: HotZone, padding: float = 120.0) -> str: cs, ce = max(0, zone.start - padding), zone.end + padding lines = [] for e in entries: if e.end > cs and e.start < ce: m, s = divmod(int(e.start), 60) h, m = divmod(m, 60) lines.append(f"[{h:02d}:{m:02d}:{s:02d}] {e.text}") return "\n".join(lines)def find_boundaries(zone: HotZone, context: str) -> List[Highlight]: prompt = f"""You are analyzing a podcast transcript to find viral-worthy clips for Chinese social media (Douyin/Xiaohongshu).YouTube viewers heavily replayed the section around {_fmt(zone.start)}-{_fmt(zone.end)} (peak engagement: {zone.peak_value:.0%} of max).Below is the transcript with timestamps. Your task:1. Find 1-3 complete "thought arcs" within or near this hot zone2. Each thought arc must have: a hook (grabs attention in first 5s), development, and a satisfying conclusion3. The clip must feel COMPLETE — a viewer should not feel like something was cut off4. Prefer the GUEST's insights over the host's questions or setup5. Each clip should be 30-120 seconds (shorter is better if the idea is complete)CRITICAL RULES:- Start at the BEGINNING of the thought (not mid-sentence)- End AFTER the thought is fully concluded- Never end before the punchline/conclusionCONTENT SELECTION PRIORITY (very important):- BEST: Insights whose CORE message is universally relatable — even if the example uses a technical domain. The key question is: does the underlying truth resonate beyond the specific field? Examples: "A student who practiced 10,000 hours at competitive programming loses to one who only practiced 100 hours" — the surface is about programming, but the CORE is about specialist vs generalist, which everyone relates to. "Without emotions you can't even choose socks" — about human nature. "Beauty and belief guide research, not just data" — about methodology and conviction.- GOOD: Vivid stories or analogies that make abstract ideas concrete. A technical example is fine if it serves a universal point.- AVOID: Segments where the CORE message itself requires domain expertise to appreciate. Industry strategy (company plans, market positioning), benchmark comparisons, architecture discussions, jargon-heavy analysis where the insight IS the technical detail. These have zero viral potential because the takeaway itself is inaccessible.The litmus test: Can you explain the core insight to a non-technical friend in one sentence and they'd say "wow that's interesting"? If the one-sentence version still requires jargon, it's not viral material.Scoring (0-10):- 9-10: Universal truth, philosophical depth, emotionally resonant — will go viral across ALL audiences- 7-8: Strong insight with a good story, accessible to most people- 5-6: Interesting but too technical or niche for mass audience- Below 5: SkipReturn JSON array (only clips scoring 6+):[ {{ "start_time": "HH:MM:SS", "end_time": "HH:MM:SS", "score": 8.5, "reason_zh": "一句话说明为什么中国观众会转发(中文)", "hook": "The opening line/idea that grabs attention (English)" }}]TRANSCRIPT:{context}""" t0 = time.time() response = call_llm(prompt) print(f" LLM: {time.time() - t0:.1f}s") try: match = re.search(r"\[.*\]", response, re.DOTALL) if not match: return [] items = json.loads(match.group()) except json.JSONDecodeError: print(" ⚠️ JSON 解析失败") return [] highlights = [] for item in items: score = float(item.get("score", 0)) if score < 6: continue start = _parse_time(item.get("start_time", "")) end = _parse_time(item.get("end_time", "")) if end <= start: continue highlights.append(Highlight(start=start, end=end, score=score, reason_zh=item.get("reason_zh", ""), hook=item.get("hook", ""))) return highlightsdef deduplicate(highlights: List[Highlight], overlap_ratio: float = 0.5) -> List[Highlight]: highlights.sort(key=lambda x: x.score, reverse=True) result = [] for h in highlights: overlaps = False for ex in result: o_s, o_e = max(h.start, ex.start), min(h.end, ex.end) if o_e > o_s: if (o_e - o_s) / min(h.end - h.start, ex.end - ex.start) > overlap_ratio: overlaps = True break if not overlaps: result.append(h) return resultdef _fmt(sec: float) -> str: h = int(sec // 3600) m = int((sec % 3600) // 60) s = int(sec % 60) return f"{h:02d}:{m:02d}:{s:02d}"def _parse_time(s: str) -> float: if not s: return 0 parts = s.split(":") try: if len(parts) == 3: return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) elif len(parts) == 2: return int(parts[0]) * 60 + float(parts[1]) except ValueError: return 0 return 0# ── 主流程 ────────────────────────────────────────────def main(): parser = argparse.ArgumentParser(description="高光检测: Heatmap + LLM") parser.add_argument("--heatmap", default="heatmap.json") parser.add_argument("--srt", default="subtitle.en.srt") parser.add_argument("--output", default="highlights.json") parser.add_argument("--top-n", type=int, default=8) parser.add_argument("--threshold", type=float, default=0.3) args = parser.parse_args() check_env() with open(args.heatmap) as f: heatmap = json.load(f) entries = parse_srt(args.srt) print(f"[数据] Heatmap: {len(heatmap)} 点, SRT: {len(entries)} 条") zones = find_hot_zones(heatmap, threshold=args.threshold) print(f"[热区] {len(zones)} 个 (threshold={args.threshold})") for i, z in enumerate(zones): print(f" {i+1}. {_fmt(z.start)}-{_fmt(z.end)} ({z.end-z.start:.0f}s) peak={z.peak_value:.2f}") all_hl = [] for i, zone in enumerate(zones): print(f"\n[分析] 热区 {i+1}: {_fmt(zone.start)}-{_fmt(zone.end)}") context = extract_context(entries, zone) hls = find_boundaries(zone, context) for h in hls: print(f" → [{h.score:.1f}] {_fmt(h.start)}-{_fmt(h.end)} ({h.end-h.start:.0f}s) {h.reason_zh}") all_hl.extend(hls) all_hl = deduplicate(all_hl)[:args.top_n] # 填充文本 for h in all_hl: h.text = " ".join(e.text for e in entries if e.end > h.start and e.start < h.end)[:500] output = { "highlights_count": len(all_hl), "highlights": [ {"rank": i + 1, "start_time": h.start, "end_time": h.end, "start_str": _fmt(h.start), "end_str": _fmt(h.end), "duration": round(h.end - h.start, 1), "score": h.score, "reason_zh": h.reason_zh, "hook": h.hook, "text": h.text} for i, h in enumerate(all_hl) ], } with open(args.output, "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2) print(f"\n{'='*50}") print(f"找到 {len(all_hl)} 个高光 → {args.output}") for i, h in enumerate(all_hl): print(f" {i+1}. [{_fmt(h.start)}-{_fmt(h.end)}] {h.end-h.start:.0f}s | {h.score} | {h.reason_zh}")if __name__ == "__main__": main()```---
- pipeline.py
```python#!/usr/bin/env python3"""Step 3: 完整 Pipelinehighlights.json → 逐个高光: 音频提取 → Whisper转录 → 断句 → 切片 → 翻译 → 烧录"""import jsonimport reimport timeimport subprocessimport argparsefrom pathlib import Pathfrom dataclasses import dataclassfrom typing import List, Optionalfrom config import ( FONT_PATH, GROQ_API_KEY, GROQ_API_URL, MINIMAX_URL, MINIMAX_API_KEY, LLM_MODEL, CN_FONTSIZE, CN_COLOR, SUB_MARGIN_BOTTOM, MAX_WORDS_PER_SEGMENT, PADDING, check_env,)@dataclassclass Word: text: str start: float end: float@dataclassclass Segment: start: float end: float en: str zh: str = ""# ── 4.1 音频提取 ─────────────────────────────────────def extract_audio(video_path: str, start: float, end: float, out_wav: str): subprocess.run( ["ffmpeg", "-y", "-ss", str(start), "-i", video_path, "-t", str(end - start), "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", out_wav], capture_output=True, check=True, )# ── 4.2 Groq Whisper 词级转录 ────────────────────────# 为什么需要这步:YouTube SRT 一条字幕覆盖 8-22 秒,粒度太粗。# Whisper 给每个词精确到 0.01s 的时间戳,才能实现字幕和语音精确同步。def groq_transcribe(wav_path: str) -> List[Word]: r = subprocess.run( ["curl", "-s", GROQ_API_URL, "-H", f"Authorization: Bearer {GROQ_API_KEY}", "-F", f"file=@{wav_path}", "-F", "model=whisper-large-v3", "-F", "language=en", "-F", "response_format=verbose_json", "-F", "timestamp_granularities[]=word", "-F", "timestamp_granularities[]=segment"], capture_output=True, text=True, timeout=120, ) data = json.loads(r.stdout) words = [] for w in data.get("words", []): text = w["word"].strip() if text: words.append(Word(text=text, start=round(w["start"], 2), end=round(w["end"], 2))) return words# ── 4.3 标点断句 + 时间重叠修复 ──────────────────────def split_sentences(words: List[Word]) -> List[Segment]: segments = [] buf: List[Word] = [] for w in words: buf.append(w) if w.text.endswith((".", "?", "!")): if buf: segments.append(_make_seg(buf)) buf = [] if buf: segments.append(_make_seg(buf)) # 长句在逗号/连词处切分 result = [] for seg in segments: if len(seg.en.split()) > MAX_WORDS_PER_SEGMENT: result.extend(_split_long(seg, words)) else: result.append(seg) # 消除时间戳重叠 for i in range(len(result) - 1): if result[i].end > result[i + 1].start: mid = (result[i].end + result[i + 1].start) / 2 result[i].end = mid result[i + 1].start = mid return resultdef _make_seg(buf: List[Word]) -> Segment: return Segment(start=buf[0].start, end=buf[-1].end, en=" ".join(w.text for w in buf))def _split_long(seg: Segment, all_words: List[Word]) -> List[Segment]: sw = [w for w in all_words if w.start >= seg.start - 0.01 and w.end <= seg.end + 0.01] if not sw: return [seg] # 逗号处切 result = [] buf: List[Word] = [] for w in sw: buf.append(w) if w.text.endswith(",") and len(buf) >= 5: result.append(_make_seg(buf)) buf = [] if buf: result.append(_make_seg(buf)) # 连词处切(仍太长的句子) conjunctions = {"and", "but", "so", "because", "then", "like", "actually", "however"} final = [] for seg2 in result: if len(seg2.en.split()) <= MAX_WORDS_PER_SEGMENT: final.append(seg2) continue sw2 = [w for w in all_words if w.start >= seg2.start - 0.01 and w.end <= seg2.end + 0.01] if not sw2: final.append(seg2) continue buf2: List[Word] = [] for w in sw2: buf2.append(w) if w.text.lower().rstrip(".,!?") in conjunctions and len(buf2) >= 10: final.append(_make_seg(buf2[:-1])) buf2 = [w] if buf2: final.append(_make_seg(buf2)) # 硬切兜底 ultra = [] for seg3 in final: if len(seg3.en.split()) <= MAX_WORDS_PER_SEGMENT + 5: ultra.append(seg3) continue sw3 = [w for w in all_words if w.start >= seg3.start - 0.01 and w.end <= seg3.end + 0.01] if not sw3: ultra.append(seg3) continue for i in range(0, len(sw3), MAX_WORDS_PER_SEGMENT): chunk = sw3[i:i + MAX_WORDS_PER_SEGMENT] if chunk: ultra.append(_make_seg(chunk)) return ultra# ── 4.4 边界精确化 + 切片 ────────────────────────────def find_clean_boundaries(sentences: List[Segment], target_start: float, target_end: float, audio_offset: float) -> tuple: if not sentences: return target_start, target_end, [] rel_start = target_start - audio_offset rel_end = target_end - audio_offset best_s = 0 for i, seg in enumerate(sentences): if seg.start <= rel_start + 1.0: best_s = i else: break best_e = len(sentences) - 1 for i in range(len(sentences) - 1, -1, -1): if sentences[i].end >= rel_end - 1.0: best_e = i else: break if best_s > best_e: best_s, best_e = best_e, best_s selected = sentences[best_s:best_e + 1] # 过滤残句和填充词 fillers = {"yeah.", "yes.", "right.", "uh-huh.", "mm-hmm.", "okay.", "ok.", "uh.", "um."} filtered = [] for i, seg in enumerate(selected): if seg.en.strip().lower() in fillers: continue if i == 0 and len(seg.en.split()) <= 3 and seg.en[0:1].islower(): continue filtered.append(seg) if not filtered: return target_start, target_end, [] return filtered[0].start + audio_offset, filtered[-1].end + audio_offset, filtereddef cut_video(video_path: str, start: float, end: float, output: str): subprocess.run( ["ffmpeg", "-y", "-ss", str(start), "-i", video_path, "-t", str(end - start), "-vf", "setpts=N/FR/TB", "-af", "asetpts=N/SR/TB", "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-avoid_negative_ts", "make_zero", "-movflags", "+faststart", output], capture_output=True, check=True, )# ── 4.5 MiniMax 翻译 + drawtext 烧录 ────────────────def call_llm(prompt: str, max_tokens: int = 16000) -> str: data = {"model": LLM_MODEL, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]} r = subprocess.run( ["curl", "-s", MINIMAX_URL, "-H", f"Authorization: Bearer {MINIMAX_API_KEY}", "-H", "Content-Type: application/json", "-H", "anthropic-version: 2023-06-01", "-d", json.dumps(data)], capture_output=True, text=True, timeout=300, ) resp = json.loads(r.stdout) for item in resp.get("content", []): if item.get("type") == "text": return item["text"] return ""def translate_segments(segments: List[Segment]) -> List[Segment]: lines = [f"{i}. {seg.en}" for i, seg in enumerate(segments)] prompt = f"""你是播客字幕翻译,目标受众是中国抖音/小红书用户。这是一期AI领域的访谈节目。将下面每句英文翻译成中文。核心原则:- 翻译要像一个中国科技博主在给朋友复述这段对话,语序自然、口语化- 意思准确完整是第一优先专有名词规则:- 公司/组织名保留英文:OpenAI、Anthropic、Google、DeepMind 等- 人名保留英文:Ilya、Elon、Sam 等- Scaling Law, Transformer, GPT, LLM, AGI, GPU, RLHF 等术语保留英文口语填充词处理:- Like / You know / So / Well / I mean / And(句首)等直接删掉- 如果某句只有填充词,翻译为空字符串 ""格式:- 逐句对应,编号一一对应,不合并不拆分- 每句尽量控制在20个汉字以内,宁可稍长也不能丢意思- 输出 JSON:{{"0": "中文", "1": "中文", ...}}{chr(10).join(lines)}""" response = call_llm(prompt) zh_map = _parse_json(response) for i, seg in enumerate(segments): seg.zh = zh_map.get(str(i), "") return segmentsdef _parse_json(response: str) -> dict: try: m = re.search(r"\{[^{}]*\}", response, re.DOTALL) if m: return json.loads(m.group()) return json.loads(response) except json.JSONDecodeError: zh_map = {} for line in response.strip().split("\n"): m = re.match(r'"?(\d+)"?\s*[::.]\s*"?(.+?)"?\s*,?\s*$', line) if m: zh_map[m.group(1)] = m.group(2).strip().strip('"') return zh_mapdef escape_drawtext(text: str) -> str: text = text.replace("'", "\u2019") for old, new in [("\\", "\\\\"), (":", "\\:"), ("%", "%%"), ("[", "\\["), ("]", "\\]"), (";", "\\;")]: text = text.replace(old, new) return textdef burn_subtitles(clip_path: str, segments: List[Segment], output: str) -> bool: valid = [s for s in segments if s.zh and len(s.zh.strip().rstrip("。,!?…")) > 2] if not valid: print(" 无有效字幕") return False MAX_CHARS = 18 parts = [] for seg in valid: zh = seg.zh enable = f"enable='between(t,{seg.start:.2f},{seg.end:.2f})'" if len(zh) <= MAX_CHARS: text = escape_drawtext(zh) parts.append( f"drawtext=fontfile={FONT_PATH}:text='{text}':fontsize={CN_FONTSIZE}" f":fontcolor={CN_COLOR}:borderw=2:bordercolor=black" f":shadowcolor=black@0.5:shadowx=1:shadowy=1" f":x=(w-text_w)/2:y=h-{SUB_MARGIN_BOTTOM}-{CN_FONTSIZE}:{enable}" ) else: mid = len(zh) // 2 best = mid for i in range(len(zh)): if zh[i] in ",、;" and abs(i - mid) < abs(best - mid): best = i if zh[best] in ",、;": l1, l2 = zh[:best + 1], zh[best + 1:] else: l1, l2 = zh[:mid], zh[mid:] gap = 8 for text, y in [(escape_drawtext(l1.strip()), f"h-{SUB_MARGIN_BOTTOM}-{CN_FONTSIZE}*2-{gap}"), (escape_drawtext(l2.strip()), f"h-{SUB_MARGIN_BOTTOM}-{CN_FONTSIZE}")]: parts.append( f"drawtext=fontfile={FONT_PATH}:text='{text}':fontsize={CN_FONTSIZE}" f":fontcolor={CN_COLOR}:borderw=2:bordercolor=black" f":shadowcolor=black@0.5:shadowx=1:shadowy=1" f":x=(w-text_w)/2:y={y}:{enable}" ) vf = "setpts=PTS-STARTPTS," + ",".join(parts) r = subprocess.run( ["ffmpeg", "-y", "-i", clip_path, "-vf", vf, "-af", "asetpts=PTS-STARTPTS", "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-pix_fmt", "yuv420p", "-force_key_frames", "0", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output], capture_output=True, text=True, ) if r.returncode != 0: print(f" 烧录失败: {r.stderr[-300:]}") return False return True# ── 单个高光处理 ─────────────────────────────────────def process_highlight(video_path: str, highlight: dict, idx: int, output_dir: str, cache_dir: str) -> Optional[str]: target_start = highlight["start_time"] target_end = highlight["end_time"] print(f"\n{'─'*60}") print(f"[{idx}] {_fmt(target_start)}-{_fmt(target_end)} | {highlight.get('score', '')} | {highlight.get('reason_zh', '')}") # 4.1 音频提取 pad_start = max(0, target_start - PADDING) pad_end = target_end + PADDING wav = f"/tmp/yt_slicer_hl_{idx}.wav" print(f" [1/5 音频] {_fmt(pad_start)}-{_fmt(pad_end)}") extract_audio(video_path, pad_start, pad_end, wav) # 4.2 Whisper 转录 print(" [2/5 转录] Groq Whisper...") t0 = time.time() words = groq_transcribe(wav) print(f" {len(words)} 词, {time.time()-t0:.1f}s") if not words: print(" ⚠️ 转录无结果") return None # 4.3 断句 + 边界精确化 sentences = split_sentences(words) print(f" [3/5 断句] {len(sentences)} 句") abs_start, abs_end, selected = find_clean_boundaries(sentences, target_start, target_end, pad_start) print(f" 原始: {_fmt(target_start)}-{_fmt(target_end)} ({target_end-target_start:.0f}s)") print(f" 精确: {_fmt(abs_start)}-{_fmt(abs_end)} ({abs_end-abs_start:.0f}s)") if not selected: print(" ⚠️ 未找到有效句子") return None # 4.4 切片 clip_name = f"clip_{idx:02d}.mp4" raw_path = str(Path(output_dir) / "raw" / clip_name) Path(raw_path).parent.mkdir(parents=True, exist_ok=True) print(f" [4/5 切片] → {clip_name}") cut_video(video_path, abs_start, abs_end, raw_path) # 调整时间戳为切片相对时间 for seg in selected: seg.start = seg.start + pad_start - abs_start seg.end = seg.end + pad_start - abs_start # 4.5 翻译 + 烧录 print(f" [5/5 字幕] 翻译 {len(selected)} 句...") t0 = time.time() selected = translate_segments(selected) print(f" 翻译: {time.time()-t0:.1f}s") for seg in selected: if seg.zh: print(f" {seg.en[:40]}") print(f" → {seg.zh}") # 缓存 cache_path = Path(cache_dir) / f"clip_{idx:02d}.json" Path(cache_dir).mkdir(parents=True, exist_ok=True) with open(cache_path, "w", encoding="utf-8") as f: json.dump([{"start": s.start, "end": s.end, "en": s.en, "zh": s.zh} for s in selected], f, ensure_ascii=False, indent=2) # 烧录 output_path = str(Path(output_dir) / clip_name) ok = burn_subtitles(raw_path, selected, output_path) if ok: print(f" ✅ {output_path}") return output_path else: print(" ❌ 烧录失败") return Nonedef _fmt(sec: float) -> str: h = int(sec // 3600) m = int((sec % 3600) // 60) s = int(sec % 60) return f"{h:02d}:{m:02d}:{s:02d}"# ── 主流程 ────────────────────────────────────────────def main(): parser = argparse.ArgumentParser(description="Pipeline: 高光 → 切片 → 字幕") parser.add_argument("--video", default="video.mp4") parser.add_argument("--highlights", default="highlights.json") parser.add_argument("--output", default="output") parser.add_argument("--cache", default="output/cache") parser.add_argument("--top-n", type=int, default=5) parser.add_argument("--only", type=int, help="只处理第 N 个") args = parser.parse_args() check_env() with open(args.highlights, encoding="utf-8") as f: data = json.load(f) highlights = data["highlights"] if args.only: highlights = [highlights[args.only - 1]] else: highlights = highlights[:args.top_n] print("=" * 60) print(f"Pipeline: {len(highlights)} 个高光 → 切片 → 中文字幕") print("=" * 60) results = {} for i, h in enumerate(highlights, 1): idx = args.only if args.only else i out = process_highlight(args.video, h, idx, args.output, args.cache) results[f"clip_{idx:02d}"] = out print(f"\n{'='*60}") print("结果:") for name, path in results.items(): print(f" {name}: {'✅ ' + path if path else '❌ 失败'}")if __name__ == "__main__": main()```---
- cover.py
```python#!/usr/bin/env python3"""封面图生成:即梦API生成背景 + ffmpeg合成人物 + 本地字体标题 → 3:4 封面流程: 1. 即梦API 文生图 → 科技风格背景(不含人物) 2. ffmpeg 合成人物照片(按调好的布局:60%高度、底部对齐、可偏移) 3. ffmpeg drawtext 叠加标题文字用法: python3 cover.py --guest "Ilya" --title "Ilya最新访谈" --hook "真正决定大模型进化的是品味" python3 cover.py --image face.png --title "标题" --bg-prompt "自定义背景prompt""""import osimport jsonimport base64import subprocessimport argparsefrom pathlib import Path# ── 配置 ──────────────────────────────────────────────WIDTH = 1080HEIGHT = 1440 # 3:4ARK_API_KEY = os.environ.get("ARK_API_KEY", "") # ⚠️ 需设置环境变量ARK_API_URL = "https://ark.cn-beijing.volces.com/api/v3/images/generations"ARK_MODEL = "doubao-seedream-5-0-260128"ASSETS_DIR = Path(__file__).parent.parent / "assets" / "silhouettes"MAPPING_FILE = ASSETS_DIR / "mapping.json"FONT_BOLD = Path("<YOUR_COVER_FONT_PATH>") # ⚠️ 填入 Alibaba Health Font 2.0 CN 粗体路径DEFAULT_BG_PROMPT = ( "深蓝黑色科技背景,发光的金色神经网络节点和连接线," "琥珀色光点分布在画面中,线条从节点向外延伸形成网状结构," "背景有深空般的渐变质感,光晕柔和。" "画面中央留出人物空间(不要画任何人物或人脸)。" "风格:科技感、未来感、高级感。画面比例 3:4 竖版。")def load_mapping() -> dict: if MAPPING_FILE.exists(): with open(MAPPING_FILE) as f: data = json.load(f) return {k: v for k, v in data.items() if not k.startswith("_")} return {}def resolve_silhouette(guest_name: str) -> str | None: mapping = load_mapping() filename = mapping.get(guest_name) if not filename: for key, val in mapping.items(): if key.lower() in guest_name.lower() or guest_name.lower() in key.lower(): filename = val break if filename: path = ASSETS_DIR / filename if path.exists(): return str(path) print(f"⚠️ 文件不存在: {path}") return Nonedef esc(text: str) -> str: text = text.replace("'", "\u2019") for old, new in [("\\", "\\\\"), (":", "\\:"), ("%", "%%"), ("[", "\\["), ("]", "\\]"), (";", "\\;")]: text = text.replace(old, new) return textdef _split_text(text: str, max_chars: int = 10) -> list[str]: if len(text) <= max_chars: return [text] lines = [] remaining = text while len(remaining) > max_chars: best = max_chars for i in range(max(0, max_chars - 3), min(len(remaining), max_chars + 3)): if remaining[i] in ",、的是了在和与": best = i + 1 break lines.append(remaining[:best].strip()) remaining = remaining[best:].strip() if remaining: lines.append(remaining) return lines# ── Step 1: 即梦API 生成背景 ─────────────────────────def generate_background(prompt: str, output_path: str) -> bool: """文生图,只生成背景,不含人物""" data = { "model": ARK_MODEL, "prompt": prompt, "size": "2K", "output_format": "png", "watermark": False, } print(" 即梦API 生成背景中...") r = subprocess.run( ["curl", "-s", ARK_API_URL, "-H", "Content-Type: application/json", "-H", f"Authorization: Bearer {ARK_API_KEY}", "-d", json.dumps(data)], capture_output=True, text=True, timeout=120, ) try: resp = json.loads(r.stdout) except json.JSONDecodeError: print(f"❌ API 返回解析失败: {r.stdout[:300]}") return False if "data" in resp and len(resp["data"]) > 0: img_data = resp["data"][0] if "b64_json" in img_data: with open(output_path, "wb") as f: f.write(base64.b64decode(img_data["b64_json"])) print(f" ✓ 背景生成完成") return True elif "url" in img_data: dl = subprocess.run( ["curl", "-s", "--connect-timeout", "10", "-o", output_path, img_data["url"]], capture_output=True, timeout=120, ) if dl.returncode == 0: print(f" ✓ 背景生成完成") return True error = resp.get("error", {}).get("message", str(resp)[:300]) print(f"❌ 生成失败: {error}") return False# ── Step 2: ffmpeg 合成人物 + 标题 ───────────────────def composite_cover( bg_path: str, person_path: str, title: str, hook: str, output: str, x_offset: int = 0,) -> bool: """背景 + 人物合成 + 标题文字""" font_bold = str(FONT_BOLD) inputs = ["-i", bg_path, "-i", person_path] filters = [] # 背景缩放到目标尺寸 filters.append( f"[0:v]scale={WIDTH}:{HEIGHT}:force_original_aspect_ratio=increase," f"crop={WIDTH}:{HEIGHT}[bg]" ) # 人物:60% 高度,底部对齐,下移 5% target_h = int(HEIGHT * 0.60) y_push = int(HEIGHT * 0.05) filters.append( f"[1:v]format=rgba,scale=-1:{target_h}[sil]" ) filters.append( f"[bg][sil]overlay=(W-w)/2+{x_offset}:H-h+{y_push}[comp]" ) # 标题文字 drawtext_parts = [] # 标题:顶部 8%,128px title_fontsize = 128 title_y = int(HEIGHT * 0.08) drawtext_parts.append( f"drawtext=fontfile='{font_bold}':text='{esc(title)}':" f"fontsize={title_fontsize}:fontcolor=white:" f"borderw=4:bordercolor=black@0.5:" f"x=(w-text_w)/2:y={title_y}" ) # 金句:标题下方 6% 间距,88px if hook: hook_fontsize = 88 hook_lines = _split_text(hook, max_chars=10) hook_start_y = title_y + title_fontsize + int(HEIGHT * 0.06) line_gap = 16 for i, line in enumerate(hook_lines): y = hook_start_y + i * (hook_fontsize + line_gap) drawtext_parts.append( f"drawtext=fontfile='{font_bold}':text='{esc(line)}':" f"fontsize={hook_fontsize}:fontcolor=white:" f"borderw=3:bordercolor=black@0.4:" f"x=(w-text_w)/2:y={y}" ) vf = ";".join(filters) + ";[comp]" + ",".join(drawtext_parts) cmd = [ "ffmpeg", "-y", *inputs, "-filter_complex", vf, "-frames:v", "1", "-update", "1", output, ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: print(f"❌ 合成失败: {r.stderr[-500:]}") return False print(f"✅ 封面 → {output} ({WIDTH}x{HEIGHT})") return True# ── 主流程 ────────────────────────────────────────────def main(): parser = argparse.ArgumentParser(description="封面图生成 (即梦背景 + 人物合成 + 标题)") parser.add_argument("--guest", help="嘉宾名(自动查找照片)") parser.add_argument("--image", help="手动指定人物照片路径") parser.add_argument("--title", required=True, help="标题") parser.add_argument("--hook", default="", help="金句") parser.add_argument("--bg-prompt", default=DEFAULT_BG_PROMPT, help="背景生图 prompt") parser.add_argument("--bg-image", help="跳过生图,直接用已有背景图") parser.add_argument("--x-offset", type=int, default=-150, help="人物水平偏移") parser.add_argument("--output", default="cover.png") args = parser.parse_args() # 找人物照片 image_path = None if args.image: image_path = args.image elif args.guest: image_path = resolve_silhouette(args.guest) if image_path: print(f"照片: {image_path}") if not image_path: parser.error("需要 --guest 或 --image") # Step 1: 生成背景 if args.bg_image: bg_path = args.bg_image else: bg_path = "/tmp/yt_slicer_cover_bg.png" ok = generate_background(args.bg_prompt, bg_path) if not ok: return # Step 2: 合成 composite_cover( bg_path=bg_path, person_path=image_path, title=args.title, hook=args.hook, output=args.output, x_offset=args.x_offset, )if __name__ == "__main__": main()```---
Assets
- silhouettes/mapping.json
```json{ "_comment": "人物名 → 剪影文件名映射。支持多个别名指向同一张图。", "Ilya Sutskever": "Ilya.png", "Ilya": "Ilya.png", "Sam Altman": "sam_altman.png", "Sam": "sam_altman.png", "Andrej Karpathy": "karpathy.png", "Karpathy": "karpathy.png", "Elon Musk": "elon.png", "Elon": "elon.png", "Lex Fridman": "lex.png", "Lex": "lex.png", "Dwarkesh Patel": "dwarkesh.png", "Dwarkesh": "dwarkesh.png", "Jensen Huang": "jensen.png", "Jensen": "jensen.png", "Dario Amodei": "dario.png", "Dario": "dario.png", "Mark Zuckerberg": "zuckerberg.png", "Zuckerberg": "zuckerberg.png"}```> ****注意****: `assets/silhouettes/` 目录下需自行准备人物透明背景 PNG 照片,并在 `mapping.json` 中注册映射。
- references
- cover-design.md
JavaScript **# 封面图设计规范**本文档是封面图生成的设计准则。生成封面后必须逐条自检。**## 布局参数**| 参数 | 值 | 说明 ||------|------|------|| 画幅 | 1080x1440 | 3:4 竖版 || 人物高度 | 画面 60% | 太小显空,太大压脸 || 人物位置 | 底部对齐 + 下移 5% | 底部略溢出,避免"悬浮感" || 人物水平 | 居中(注意是人物居中,非图片居中) | 原图人物偏侧时用 `--x-offset` 校正 || 标题位置 | 顶部 8% | — || 标题字号 | 128px | Alibaba Health Font 2.0 CN 粗体 || 金句位置 | 标题下方,间距 6% | — || 金句字号 | 88px | 同上字体 || 金句换行 | 每行 ≤10 字 | 在标点处断行 |**## 颜色规则**- ****深色背景****(AI 生成):白字 + 黑色描边 (borderw=4, bordercolor=black@0.5)- ****亮色背景****(纯色):黑字,无描边**## 三步流程**即梦API文生图(只背景) → ffmpeg合成人物 → ffmpeg叠标题****绝对不要让 AI 控制人物的比例和位置。**** AI 的图生图模式会改变人物大小和构图。正确做法:AI 只生成背景(prompt 明确写"不要画任何人物或人脸"),人物用 ffmpeg overlay 精确控制。**## AI 背景生图 prompt 模板**深蓝黑色科技背景,发光的金色神经网络节点和连接线,琥珀色光点分布在画面中,线条从节点向外延伸形成网状结构,背景有深空般的渐变质感,光晕柔和。画面中央留出人物空间(不要画任何人物或人脸)。风格:科技感、未来感、高级感。画面比例 3:4 竖版。可根据视频主题调整风格关键词,但始终保留"不要画人物"的约束。**## 自检清单**生成封面后,逐条检查:- [ ] 人物大小合适?(不能太小显空,不能太大压脸)- [ ] 人物水平居中?(不偏左不偏右)- [ ] 标题和金句清晰可读?(文字不被人物遮挡)- [ ] 没有大块空白?(文字和人物之间紧凑,有叠压感)- [ ] 整体构图平衡?(上文字、下人物,视觉重心稳)- [ ] 无水印?(watermark: false)- [ ] 金句换行自然?(不在奇怪的地方断行)**## 人物照片要求**- 格式:PNG,透明背景- 内容:半身照或头肩照,正面或微侧- 存放:`assets/silhouettes/`,在 `mapping.json` 中注册名字映射- 如果原图人物不居中,使用 `--x-offset` 校正(负值=左移)---## 3. 配置说明使用前需要配置以下内容:| 项目 | 文件 | 说明 ||------|------|------||GROQ_API_KEY| 环境变量 | Groq Whisper API key,用于英文语音转录 ||MINIMAX_API_KEY| 环境变量 | MiniMax API key,用于翻译和高光分析(模型: MiniMax-M2.7) ||ARK_API_KEY| 环境变量 | 火山引擎 即梦 API key,用于封面背景生图(模型: doubao-seedream-5) ||SUBTITLE_FONT| 环境变量(可选) | 字幕字体路径,需要 思源黑体 CN (Source Han Sans CN)。默认值在config.py中需替换为你本地的路径 || 封面标题字体 |cover.py中FONT_BOLD| 需要 阿里巴巴健康体 2.0 CN 粗体 (Alibaba Health Font 2.0 CN Bold),替换为你本地的.ttf路径 || 人物照片 |assets/silhouettes/| 透明背景 PNG 半身照,在mapping.json中注册名字映射 |### 环境变量设置示例bashexport GROQ_API_KEY="gsk_xxxxxxxxxxxxxxxxxxxxxxxx"export MINIMAX_API_KEY="eyJhbGciOixxxxxxxxxxxxxxxx"export ARK_API_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"export SUBTITLE_FONT="/path/to/SourceHanSansCN-Regular.otf"### 系统依赖bash# macOSbrew install ffmpeg yt-dlp python3# 确认版本ffmpeg -versionyt-dlp --versionpython3 --version### 字体下载- 思源黑体 CN: https://github.com/adobe-fonts/source-han-sans/releases — 下载SourceHanSansCN-Regular.otf- 阿里巴巴健康体 2.0 CN: https://www.alibabafonts.com/ — 下载粗体 (85 Bold).ttf文件### 代理说明download.py会自动检测本地代理(扫描常见端口 1087/7890/10809 等)。如果你的网络可以直接访问 YouTube,无需额外配置。也可手动指定:bashpython3 download.py <url> --proxy http://127.0.0.1:7890
