← 返回首页ai798LAB Public

vibe-coding-课程-干货集训

访谈视频自动切片

2026年3月25日Python

生成示例

视频封面图

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

  1. 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 字体")```---
  1. 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()```---
  1. 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()```---
  1. 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()```---
  1. 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

  1. 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` 中注册映射。
  1. references
  2. 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.pyFONT_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