Skill: xunnv/video-japanese-subtitle (version 1.0.0)

--- name: video-subtitle description: 日语视频自动翻译烧录技能。将日语视频转换为中文硬字幕，完整流程：ffmpeg 提取音频 → Whisper 日语转录 → LLM 翻译日→中 → SRT 转 ASS → ffmpeg NVENC 烧录硬字幕 → 验证。触发条件：用户提到视频字幕、硬字幕、字幕烧录、日语视频翻译、whisper 字幕、ass 字幕、或要求处理 .mp4 视频加字幕。 --- # 视频字幕翻译烧录技能 ## 工作流程（6 步） ``` 📦 原始视频.mp4 → 🎵 提取音频（ffmpeg，~10s） → 📝 Whisper 转录日语（~3-5min，base 模型） → 📝 LLM 翻译日→中（~2-3min，QClaw 网关） → 📝 SRT 转 ASS（<1s） → 🎬 NVENC GPU 烧录（~3-6min） → ✅ ffprobe 验证 ``` ## 运行方式 ```powershell # 默认路径运行 python C:\Users\liket\.agents\skills\video-subtitle\scripts\subtitle_translate.py # 自定义路径（修改脚本顶部的配置变量） # VIDEO_DIR 视频目录 # OUTPUT_DIR 输出目录 # FFMPEG_DIR ffmpeg bin 目录 # QCLAW_GATEWAY LLM 网关地址 # QCLAW_TOKEN LLM 网关 Token ``` **环境要求：** ffmpeg（已加 PATH）、Python openai-whisper、Python deep-translator（MyMemory 回退用） ## 踩坑速查 | # | 错误 | 原因 | 解决 | |---|------|------|------| | 1 | `WinError 2 ffmpeg not found` | Whisper 内部调 ffmpeg 但 PATH 里没有 | 脚本开头 `os.environ["PATH"]` 加入 ffmpeg 目录 | | 2 | `Unable to parse` SRT | ffmpeg ass 滤镜不支持 SRT | 先转 ASS（Step 4） | | 3 | `Unable to parse original_size` | Windows `C:` 冒号被当分隔符 | **用相对路径 + `cwd=ass_dir`**（脚本已内置） | | 4 | Whisper translate 质量差 | ja→zh 用 Whisper 翻译效果差 | 只转录，翻译交给 Step 3 LLM | | 5 | **烧录后视频无字幕** | ffmpeg 静默失败（路径问题） | **必须用 ffprobe 抽检画面**，不能只看文件大小 | | 6 | LLM 翻译条目数不匹配 | 输出格式不稳定 | 脚本内置兜底补空逻辑 | | 7 | GBK 解码错误 | Windows 控制台默认 GBK | Python 脚本开头设 `sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')` | ## 关键实现细节 **Whisper PATH 修复（脚本内置）：** ```python os.environ["PATH"] = r"E:\ffmpeg\SCP\ffmpeg-master-latest-win64-gpl\bin" + os.pathsep + os.environ["PATH"] ``` **烧录相对路径 + cwd（脚本内置）：** ```python ass_dir = os.path.dirname(os.path.abspath(ass_path)) ass_filename = os.path.basename(ass_path) subprocess.run(cmd, cwd=ass_dir) # cwd 切到字幕目录，相对路径才有效 ``` **ASS 字幕样式：** Microsoft YaHei UI，白字黑边（PrimaryColour `&H00FFFFFF`，Outline=2），底部居中。 **NVENC 自动回退 CPU：** GPU 编码失败时自动降级到 `libx264 -preset ultrafast`。 ## 性能参考（RTX 4060） | 步骤 | 30min 视频 | |------|-----------| | 提取音频 | ~10s | | Whisper base | ~3-5min | | LLM 翻译（~700条） | ~2-3min | | SRT→ASS | <1s | | NVENC 烧录 | ~3-6min | | **合计** | **~10-15min/视频** | ## 验证命令 ```bash # 检查时长（必须，但不够） ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "output.mp4" # 截帧验证字幕真的烧进去了（必须！） ffmpeg -ss 00:05:00 -i "output.mp4" -vframes 1 -q:v 2 frame.jpg # 然后用图片查看器确认画面底部有字幕 ``` ## 目录结构 ``` 视频目录\ ├── 原始视频.mp4 ├── output\ ← 脚本输出目录（可自定义） │ ├── video_xxx_subtitled.mp4 成品 │ ├── xxx_audio.wav 中间音频 │ ├── xxx_subs.srt 日语原文 │ ├── xxx_subs_zh.srt 中文翻译 │ └── xxx_subs_zh.ass 中文 ASS └── final\ ← 可手动建此目录存放成品 ``` ## 脚本特性 - 断点续传：每步检查文件是否存在，已完成则跳过 - 短 hash 文件名：绕过 Windows 260 字符路径限制 - NVENC/CPU 自动切换 - LLM + MyMemory 双翻译引擎（LLM 失败自动回退） ## 已知局限 - Whisper base 模型对混合音频（BGM+语音+音效）识别一般 - `--task translate`（ja→zh）翻译质量差，不用 - 当前只支持日语→中文，其他语言需修改 Whisper language 参数 - faster-whisper 替代 openai-whisper 可提升速度和内存效率（未来改进方向）

path: scripts/subtitle_translate.py
content: #!/usr/bin/env python3
"""
视频字幕翻译烧录脚本 v1
流程：提取音频 → faster-whisper 转录日语 → QClaw LLM 翻译日→中 → SRT转ASS → ffmpeg烧录

环境要求：
- ffmpeg (PATH已配置)
- Python: faster-whisper, deep-translator
- QClaw 网关 (本地)

使用方法：
  python subtitle_translate_v1.py

输出：output/ 目录
"""

import os
import sys
import re
import json
import time
import hashlib
import subprocess
import urllib.request
import urllib.error
from pathlib import Path

# ============ 配置 ============
VIDEO_DIR = Path(r"D:\Users\liket\Desktop\000")
OUTPUT_DIR = Path(r"D:\Users\liket\Desktop\000\output")
FFMPEG_DIR = Path(r"E:\ffmpeg\SCP\ffmpeg-master-latest-win64-gpl\bin")
WHISPER_MODEL = "base"  # base / small / medium / large-v3
WHISPER_LANG = "ja"

# QClaw 网关翻译配置
QCLAW_GATEWAY = "http://127.0.0.1:28789/v1/chat/completions"
QCLAW_TOKEN = "78238b99f7ed3fb71de138d875005ac5a80274920af729fe"
TRANSLATE_BATCH_SIZE = 15  # 每批翻译的 SRT 条目数
TRANSLATE_MODEL = "openclaw"

# ffmpeg PATH（Whisper 内部也用 ffmpeg）
os.environ["PATH"] = str(FFMPEG_DIR) + os.pathsep + os.environ.get("PATH", "")
FFMPEG = str(FFMPEG_DIR / "ffmpeg.exe")
FFPROBE = str(FFMPEG_DIR / "ffprobe.exe")

# ASS 字幕样式
ASS_HEADER = """[Script Info]
Title: Translated Subtitles
ScriptType: v4.00+
PlayResX: 1280
PlayResY: 720
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Microsoft YaHei UI,36,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,10,10,30,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""


def file_hash(path):
    """取文件名 hash 作为短名"""
    return hashlib.md5(path.encode('utf-8')).hexdigest()[:8]


def get_video_list():
    """扫描视频目录"""
    videos = []
    for f in sorted(Path(VIDEO_DIR).iterdir()):
        if f.suffix.lower() == '.mp4' and f.is_file():
            videos.append(f)
    return videos


def extract_audio(video_path, audio_path):
    """提取音频为 WAV"""
    print(f"  [1/5] 提取音频: {video_path.name}")
    cmd = [
        FFMPEG, "-y", "-i", str(video_path),
        "-vn", "-acodec", "pcm_s16le",
        "-ar", "16000", "-ac", "1",
        str(audio_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    if result.returncode != 0:
        print(f"    ⚠ 音频提取失败: {result.stderr[-200:]}")
        return False
    print(f"    ✓ 音频已提取")
    return True


def transcribe_audio(audio_path, srt_path):
    """用 openai-whisper 转录日语"""
    print(f"  [2/5] Whisper 转录日语...")
    import whisper
    
    model = whisper.load_model(WHISPER_MODEL)
    result = model.transcribe(
        str(audio_path),
        language=WHISPER_LANG,
        task="transcribe",
        verbose=False
    )
    
    srt_entries = []
    for i, seg in enumerate(result['segments'], 1):
        start = format_srt_time(seg['start'])
        end = format_srt_time(seg['end'])
        text = seg['text'].strip()
        if text:
            srt_entries.append(f"{i}\n{start} --> {end}\n{text}\n")
    
    with open(srt_path, 'w', encoding='utf-8') as f:
        f.write('\n'.join(srt_entries))
    
    print(f"    ✓ 转录完成: {len(srt_entries)} 条")
    return len(srt_entries) > 0


def format_srt_time(seconds):
    """秒数转 SRT 时间格式"""
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    ms = int((seconds % 1) * 1000)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


def parse_srt(srt_path):
    """解析 SRT 文件"""
    with open(srt_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    entries = []
    blocks = re.split(r'\n\s*\n', content.strip())
    for block in blocks:
        lines = block.strip().split('\n')
        if len(lines) >= 3:
            idx = int(lines[0].strip())
            time_match = re.match(
                r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})',
                lines[1].strip()
            )
            if time_match:
                start = time_match.group(1)
                end = time_match.group(2)
                text = '\n'.join(lines[2:]).strip()
                entries.append({
                    'index': idx,
                    'start': start,
                    'end': end,
                    'text': text
                })
    return entries


def translate_with_qclaw(texts, retry=3):
    """用 QClaw 网关 LLM 批量翻译日语→中文"""
    if not texts:
        return texts
    
    # 构建翻译 prompt
    numbered = '\n'.join(f'{i+1}. {t}' for i, t in enumerate(texts))
    user_msg = f"请将以下日语逐行翻译为简体中文，保留编号格式（1. 2. 3. ...），只输出翻译结果：\n\n{numbered}"
    
    payload = json.dumps({
        'model': TRANSLATE_MODEL,
        'messages': [
            {
                'role': 'system',
                'content': '你是一个日语到简体中文的专业翻译器。只输出翻译结果，不加任何解释、注释或额外文字。保留原文的编号格式。'
            },
            {
                'role': 'user',
                'content': user_msg
            }
        ],
        'max_tokens': 2000,
        'temperature': 0.1
    }).encode('utf-8')
    
    req = urllib.request.Request(
        QCLAW_GATEWAY,
        data=payload,
        headers={
            'Authorization': f'Bearer {QCLAW_TOKEN}',
            'Content-Type': 'application/json'
        }
    )
    
    for attempt in range(retry):
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                result = json.loads(resp.read().decode('utf-8'))
                content = result['choices'][0]['message']['content'].strip()
                return parse_translated_lines(content, len(texts))
        except Exception as e:
            print(f"    翻译请求失败 (尝试 {attempt+1}/{retry}): {e}")
            time.sleep(2 * (attempt + 1))
    
    # 全部失败，回退到 MyMemory
    print(f"    ⚠ LLM 翻译全部失败，回退 MyMemory...")
    return translate_with_mymemory(texts)


def parse_translated_lines(content, expected_count):
    """解析 LLM 翻译结果，提取编号行"""
    lines = content.split('\n')
    translated = []
    for line in lines:
        line = line.strip()
        if not line:
            continue
        # 去掉编号前缀 (1. 2. 等)
        match = re.match(r'^\d+[\.\)、]\s*(.*)', line)
        if match:
            translated.append(match.group(1).strip())
        else:
            translated.append(line)
    
    # 如果数量不匹配，尝试其他解析方式
    if len(translated) != expected_count:
        # 可能没有编号，直接按行分割
        alt = [l.strip() for l in lines if l.strip()]
        if len(alt) == expected_count:
            return alt
        print(f"    ⚠ 翻译条目数不匹配: 期望{expected_count}, 实际{len(translated)}")
        # 尽量对齐
        while len(translated) < expected_count:
            translated.append("")
    
    return translated[:expected_count]


def translate_with_mymemory(texts):
    """回退：用 MyMemory 翻译"""
    from deep_translator import MyMemoryTranslator
    translator = MyMemoryTranslator(source='ja-JP', target='zh-CN')
    results = []
    for text in texts:
        try:
            t = translator.translate(text)
            results.append(t if t else text)
            time.sleep(0.3)  # 避免限流
        except:
            results.append(text)  # 翻译失败保留原文
    return results


def translate_srt(srt_path, translated_srt_path):
    """翻译 SRT 文件：日语→中文"""
    print(f"  [3/5] 翻译字幕 日→中...")
    entries = parse_srt(srt_path)
    if not entries:
        print("    ⚠ 无有效字幕条目")
        return False
    
    # 批量翻译
    all_texts = [e['text'] for e in entries]
    all_translated = []
    
    total_batches = (len(all_texts) + TRANSLATE_BATCH_SIZE - 1) // TRANSLATE_BATCH_SIZE
    for i in range(0, len(all_texts), TRANSLATE_BATCH_SIZE):
        batch = all_texts[i:i+TRANSLATE_BATCH_SIZE]
        batch_num = i // TRANSLATE_BATCH_SIZE + 1
        print(f"    翻译批次 {batch_num}/{total_batches} ({len(batch)} 条)...")
        
        translated = translate_with_qclaw(batch)
        all_translated.extend(translated)
        time.sleep(1)  # 避免请求过快
    
    # 写入翻译后的 SRT
    with open(translated_srt_path, 'w', encoding='utf-8') as f:
        for i, entry in enumerate(entries):
            translated_text = all_translated[i] if i < len(all_translated) else entry['text']
            f.write(f"{entry['index']}\n")
            f.write(f"{entry['start']} --> {entry['end']}\n")
            f.write(f"{translated_text}\n\n")
    
    print(f"    ✓ 翻译完成: {len(entries)} 条")
    return True


def srt_to_ass(srt_path, ass_path):
    """SRT 转 ASS 格式"""
    print(f"  [3.5] SRT → ASS 格式转换...")
    entries = parse_srt(srt_path)
    
    with open(ass_path, 'w', encoding='utf-8') as f:
        f.write(ASS_HEADER)
        for entry in entries:
            start = srt_time_to_ass(entry['start'])
            end = srt_time_to_ass(entry['end'])
            text = entry['text'].replace('\n', '\\N')
            f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}\n")
    
    print(f"    ✓ ASS 生成完成")
    return True


def srt_time_to_ass(srt_time):
    """SRT 时间格式转 ASS 时间格式"""
    # 00:00:05,000 → 0:00:05.00
    h, m, s = srt_time.replace(',', '.').split(':')
    s_int, s_dec = s.split('.')
    s_dec = s_dec[:2]  # 取前两位
    return f"{int(h)}:{m}:{s_int}.{s_dec}"


def burn_subtitles(video_path, ass_path, output_path):
    """烧录字幕到视频（NVENC 优先，CPU 回退）"""
    print(f"  [4/5] 烧录字幕...")
    
    # 使用相对路径 + cwd 避免 Windows 盘符冒号问题
    ass_dir = os.path.dirname(os.path.abspath(ass_path))
    ass_filename = os.path.basename(ass_path)
    
    # 尝试 NVENC
    cmd_nvenc = [
        FFMPEG, "-y",
        "-i", str(video_path),
        "-vf", f"ass={ass_filename}",
        "-c:v", "h264_nvenc",
        "-preset", "p4",
        "-cq", "23",
        "-c:a", "copy",
        str(output_path)
    ]
    
    # CPU 回退
    cmd_cpu = [
        FFMPEG, "-y",
        "-i", str(video_path),
        "-vf", f"ass={ass_filename}",
        "-c:v", "libx264",
        "-preset", "ultrafast",
        "-crf", "23",
        "-c:a", "copy",
        str(output_path)
    ]
    
    for label, cmd in [("NVENC GPU", cmd_nvenc), ("CPU libx264", cmd_cpu)]:
        print(f"    尝试 {label}...")
        try:
            result = subprocess.run(
                cmd,
                cwd=ass_dir,  # 关键：设置工作目录为字幕文件所在目录
                capture_output=True,
                text=True,
                timeout=3600  # 1小时超时
            )
            if result.returncode == 0:
                size_mb = os.path.getsize(output_path) / (1024*1024)
                print(f"    ✓ {label} 烧录完成 ({size_mb:.1f} MB)")
                return True
            else:
                err = result.stderr[-300:] if result.stderr else "unknown"
                print(f"    ✗ {label} 失败: {err}")
        except subprocess.TimeoutExpired:
            print(f"    ✗ {label} 超时")
        except Exception as e:
            print(f"    ✗ {label} 异常: {e}")
    
    return False


def verify_video(video_path):
    """用 ffprobe 验证视频完整性"""
    cmd = [
        FFPROBE, "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        str(video_path)
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode == 0 and result.stdout.strip():
            duration = float(result.stdout.strip())
            return duration > 0
    except:
        pass
    return False


def process_video(video_path):
    """处理单个视频的完整流程"""
    h = file_hash(str(video_path))
    print(f"\n{'='*60}")
    print(f"处理: {video_path.name} (hash: {h})")
    print(f"{'='*60}")
    
    # 工作目录
    work_dir = Path(OUTPUT_DIR)
    work_dir.mkdir(parents=True, exist_ok=True)
    
    # 文件路径
    audio_path = work_dir / f"{h}_audio.wav"
    srt_path = work_dir / f"{h}_subs.srt"
    translated_srt_path = work_dir / f"{h}_subs_zh.srt"
    ass_path = work_dir / f"{h}_subs_zh.ass"
    output_path = work_dir / f"video_{h}_subtitled.mp4"
    
    # 检查是否已完成
    if output_path.exists() and verify_video(output_path):
        print(f"  ✓ 已存在，跳过")
        return True
    
    start_time = time.time()
    
    # Step 1: 提取音频
    if not audio_path.exists():
        if not extract_audio(video_path, audio_path):
            return False
    else:
        print(f"  [1/5] 音频已存在，跳过提取")
    
    # Step 2: Whisper 转录
    if not srt_path.exists():
        if not transcribe_audio(audio_path, srt_path):
            return False
    else:
        print(f"  [2/5] SRT 已存在，跳过转录")
    
    # Step 3: 翻译字幕
    if not translated_srt_path.exists():
        if not translate_srt(srt_path, translated_srt_path):
            return False
    else:
        print(f"  [3/5] 翻译字幕已存在，跳过")
    
    # Step 3.5: SRT 转 ASS
    if not ass_path.exists():
        if not srt_to_ass(translated_srt_path, ass_path):
            return False
    else:
        print(f"  [3.5] ASS 已存在，跳过转换")
    
    # Step 4: 烧录字幕
    if not burn_subtitles(video_path, ass_path, output_path):
        return False
    
    # Step 5: 验证
    print(f"  [5/5] 验证视频...")
    if verify_video(output_path):
        elapsed = time.time() - start_time
        print(f"  ✓ 全部完成！耗时 {elapsed:.0f} 秒")
        return True
    else:
        print(f"  ✗ 视频验证失败")
        return False


def main():
    print("字幕翻译烧录 v1")
    print(f"视频目录: {VIDEO_DIR}")
    print(f"输出目录: {OUTPUT_DIR}")
    print(f"翻译方式: QClaw LLM (回退 MyMemory)")
    print()
    
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    
    videos = get_video_list()
    print(f"找到 {len(videos)} 个视频文件\n")
    
    success = 0
    failed = 0
    
    for i, video in enumerate(videos, 1):
        print(f"\n>>> 进度: {i}/{len(videos)}")
        if process_video(video):
            success += 1
        else:
            failed += 1
    
    print(f"\n{'='*60}")
    print(f"全部完成！成功: {success}, 失败: {failed}")
    print(f"输出目录: {OUTPUT_DIR}")


if __name__ == "__main__":
    main()


path: references/workflow.md
content: # 视频字幕工作流（日→中硬字幕）

> 从日语视频到中文硬字幕的完整流水线，基于 2026-04-25 实战经验
> 环境：Windows 10 · RTX 4060 · Python 3.12

---

## 流程图

```
📦 原始视频.mp4
 │
 ▼  Step 1 — 提取音频
🎵 _audio.wav（16kHz 单声道 PCM）
 │
 ▼  Step 2 — 语音转录
📝 _subs.srt（日语原文，Whisper base）
 │
 ▼  Step 3 — LLM 翻译
📝 _subs_zh.srt（中文翻译，QClaw 网关）
 │
 ▼  Step 4 — 格式转换
📝 _subs_zh.ass（ASS 字幕，ffmpeg 必需）
 │
 ▼  Step 5 — 烧录硬字幕
🎬 video_xxx_subtitled.mp4（NVENC GPU 加速）
 │
 ▼  Step 6 — 验证
✅ ffprobe 确认时长 + 画面抽检
```

---

## 各步骤详解

### Step 1 · 提取音频

```bash
ffmpeg -i "视频.mp4" -vn -acodec pcm_s16le -ar 16000 -ac 1 -y "audio.wav"
```

| 参数 | 含义 |
|------|------|
| `-vn` | 忽略视频轨 |
| `-acodec pcm_s16le` | 16 位 PCM 无压缩 |
| `-ar 16000` | 16kHz 采样率（Whisper 要求） |
| `-ac 1` | 单声道 |

⏱ ~10 秒 / 30 分钟视频

---

### Step 2 · Whisper 转录日语

**前置：把 ffmpeg 加进 PATH（Whisper 内部要调用）**

```python
import os
os.environ["PATH"] = r"E:\ffmpeg\SCP\ffmpeg-master-latest-win64-gpl\bin" + os.pathsep + os.environ["PATH"]
```

**转录**

```python
import whisper
model = whisper.load_model("base")
result = model.transcribe("audio.wav", language="ja", task="transcribe")
# task="transcribe" 只转录原文，不用 "translate"（ja→zh 质量极差）
```

**模型选择**

| 模型 | 显存 | 30min 耗时 | 日语质量 | 场景 |
|------|------|-----------|----------|------|
| base | ~1 GB | 3-5 min | 一般 ✅ | 批量处理（当前） |
| small | ~2 GB | 6-10 min | 较好 | 单视频精校 |
| large-v3 | ~10 GB | 20+ min | 最好 | 终极质量 |

⚠️ 不要用 `task="translate"` 做日→中，翻译交给 Step 3 的 LLM

⏱ ~3-5 分钟 / 30 分钟视频（base 模型）

---

### Step 3 · LLM 翻译日→中

把 SRT 条目按批发给 LLM，翻译后写回新 SRT（时间戳不变）。

- **翻译引擎**：QClaw 本地网关 `http://127.0.0.1:28789/v1/chat/completions`
- **批次大小**：每批 15 条
- **回退**：LLM 失败 → MyMemory API

```
SRT 条目 → 按 15 条分批 → 每批发给 LLM → 解析编号行 → 拼回 SRT
```

Prompt 模板：

```
System: 你是日语到简体中文的专业翻译器。只输出翻译结果，不加解释。保留编号格式。

User:
请将以下日语逐行翻译为简体中文，保留编号格式（1. 2. 3. ...），只输出翻译结果：

1. こんにちは
2. ありがとう
...
```

⚠️ LLM 输出条目数可能不匹配，需兜底补空

⏱ ~2-3 分钟 / 691 条字幕（47 批次）

---

### Step 4 · SRT → ASS 格式转换

**为什么必须转？** ffmpeg 的 `ass` 滤镜不认 SRT，直接用会报 `Unable to parse`

转换要点：
- SRT 时间 `00:00:05,000` → ASS 时间 `0:00:05.00`
- 换行符 `\n` → `\\N`
- 附加 ASS 头部（样式定义）

字幕样式：
- 字体：`Microsoft YaHei UI`，字号 36
- 白字黑边（PrimaryColour=`&H00FFFFFF`，Outline=2）
- 底部居中（Alignment=2）

⏱ <1 秒

---

### Step 5 · ffmpeg 烧录硬字幕

**⚠️ 最关键的一条：Windows 路径冒号问题**

ffmpeg 的 `ass` 滤镜会把 `C:\path` 里的冒号当参数分隔符，导致静默失败。

**解决方案：用相对路径 + `cwd` 切到字幕所在目录**

```python
ass_filename = os.path.basename(ass_path)   # 只取文件名
ass_dir = os.path.dirname(os.path.abspath(ass_path))  # 目录单独给 cwd

# GPU 加速（推荐）
cmd = [FFMPEG, "-y", "-i", video_path,
       "-vf", f"ass={ass_filename}",      # ← 相对文件名，不含盘符
       "-c:v", "h264_nvenc", "-preset", "p4", "-cq", "23",
       "-c:a", "copy",
       output_path]

subprocess.run(cmd, cwd=ass_dir)  # ← 关键：cwd 切到字幕目录
```

| 编码器 | 速度 | 质量 | 30min 耗时 |
|--------|------|------|-----------|
| `h264_nvenc`（GPU）| 6-10x 实时 | 好 | 3-6 min |
| `libx264 -preset ultrafast`（CPU）| 1-2x 实时 | 可接受 | 15-30 min |

GPU 不可用时自动回退 CPU。

⏱ ~3-6 分钟 / 30 分钟视频（GPU NVENC）

---

### Step 6 · 验证

```bash
# 检查时长
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "output.mp4"

# ⚠️ 不能只看时长！还要抽检画面确认字幕真的烧进去了
# （之前踩过坑：文件正常生成但画面无字幕）
```

---

## 踩坑速查表

| # | 问题 | 原因 | 解法 |
|---|------|------|------|
| 1 | `WinError 2 ffmpeg not found` | Whisper 内部调 ffmpeg 但 PATH 里没有 | 脚本开头 `os.environ["PATH"]` 加 ffmpeg |
| 2 | `Unable to parse` SRT | ffmpeg 不支持 SRT 格式 | 先转 ASS（Step 4） |
| 3 | `Unable to parse original_size` | Windows `C:` 冒号被当分隔符 | 相对路径 + `cwd` |
| 4 | PowerShell 日语文件名乱码 | 编码问题 | 用 Python 调 ffmpeg |
| 5 | 路径超 260 字符 | Windows MAX_PATH | 短 hash 文件名 |
| 6 | 进程 SIGKILL | 超时/内存 | 检查输出时长是否完整 |
| 7 | `\xa0` 空格 | non-breaking space | `.replace('\xa0', ' ')` |
| 8 | Whisper translate 质量差 | ja→zh 翻译不行 | 只转录 + LLM 翻译 |
| 9 | **烧录后视频无字幕** | ffmpeg 静默失败 | **必须抽检画面**，不能只看文件大小 |
| 10 | LLM 翻译条目数不匹配 | 输出格式不稳定 | 兜底补空 |
| 11 | GBK 解码错误 | Windows 控制台默认 GBK | `io.TextIOWrapper(encoding='utf-8')` |

---

## 性能参考（RTX 4060）

| 步骤 | 30min 视频 | 9 个视频 |
|------|-----------|---------|
| 提取音频 | ~10s | ~2 min |
| Whisper base | ~3-5 min | ~30-45 min |
| LLM 翻译 | ~2-3 min | ~15-25 min |
| SRT→ASS | <1s | ~5s |
| NVENC 烧录 | ~3-6 min | ~30-55 min |
| **合计** | **~10-15 min** | **~1.5-2 h** |

---

## 目录结构

```
D:\Users\liket\Desktop\000\
 ├── 原始视频.mp4            ← 不动
 ├── final\                  ← 成品
 │   └── video_xxx_subtitled.mp4
 └── temp\                   ← 中间文件（可删）
     ├── video_xxx_audio.wav
     ├── video_xxx_subs.srt         日语原文
     ├── video_xxx_subs_zh.srt      中文翻译
     └── video_xxx_subs_zh.ass      中文 ASS
```

---

## 一键运行

```powershell
python C:\Users\liket\.qclaw\workspace\subtitle_translate_v1.py
```

特性：自动扫描 · 断点续传 · NVENC/CPU 自动切换 · LLM+MyMemory 双翻译引擎

---

## 待改进

- [ ] 用 **faster-whisper**（CTranslate2 后端）替代原版 Whisper，更快更省内存
- [ ] 用 **large-v3** 模型提升混合音频（BGM+语音）识别率
- [ ] 烧录验证改为**截帧抽检**（当前只查时长，不够）
- [ ] 支持**双语字幕**（日语+中文上下排列）
- [ ] 封装为 QClaw Skill 一键调用


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)