Skip to content

这个工作流非常普遍,通常分为三步:

  1. 从视频中提取高质量的音频
  2. 使用带有时间戳功能的语音转文字(ASR)工具处理该音频文件,生成字幕文件(如 .srt.vtt
  3. 将字幕文件与原始视频合并

ffmpeg 在第一步和第三步中扮演着至关重要的角色,第二步可以考虑使用开源的语音转文字工具,如 OpenAI Whisper

核心概念:时间戳是如何工作的

首先要明确一点:ffmpeg 在提取音频时,默认就会保留原始的时间戳信息。视频文件(如 MKV)中的每一个音频帧和视频帧都有一个精确的播放时间点(Presentation Timestamp, PTS)。当 ffmpeg 将音频流提取并保存为一个独立的音频文件(如 WAV 或 M4A)时,这些音频帧的相对时间关系会被完整地保留下来。后续的字幕生成工具在分析这个音频文件时,会识别出语音的起止时刻,并根据音频文件本身的时间轴来生成带有 开始时间 --> 结束时间 格式的字幕。

因此,你需要做的就是正确地提取出音频,时间戳的问题 ffmpeg 会自动处理好。

步骤1.1:分析你的 MKV 文件(推荐)

在提取之前,最好先查看一下 .mkv 文件里包含了哪些流(视频、音频、字幕等),特别是当视频可能包含多条音轨(如不同语言的配音、评论音轨)时。

打开终端或命令提示符,运行以下命令:

bash
ffmpeg -i "你的视频文件.mkv"

注意:如果文件名包含空格,请用引号括起来。

ffmpeg 会打印出文件的详细信息。在输出中找到类似下面的部分:

Input #0, matroska,webm, from '你的视频文件.mkv':
  Metadata:
    ...
  Duration: 01:23:45.67, start: 0.000000, bitrate: 4531 kb/s
  Stream #0:0(eng): Video: h264 (High), yuv420p(progressive), 1920x1080, ...
  Stream #0:1(jpn): Audio: opus, 48000 Hz, stereo, fltp (default)
  Stream #0:2(chi): Audio: aac, 48000 Hz, stereo, fltp
  Stream #0:3(eng): Subtitle: ass

解读关键信息

  • Stream #0:1(jpn): 这是第一条音轨,语言是日语(jpn),编码是 Opus。它是 (default) 默认音轨。
  • Stream #0:2(chi): 这是第二条音轨,语言是中文(chi),编码是 AAC。

假设你想要提取中文音轨,那么它的标识符就是 0:2。如果只有一个音轨,通常是 0:1

步骤1.2:提取音频,提取为 WAV 格式(强烈推荐用于字幕生成)

WAV 是无损的未压缩音频格式,能为后续的语音识别提供最高的音频质量,从而生成更准确的字幕。

命令格式

bash
ffmpeg -i "你的视频文件.mkv" -map 0:a:N -c:a pcm_s16le -ar 16000 "输出音频.wav"

命令详解

  • -i "你的视频文件.mkv": 指定输入文件。
  • -map 0:a:N: 这是选择音轨的关键。
    • 0: 代表第一个输入文件。
    • a: 代表音频(audio)。
    • N: 代表音轨的索引(从0开始)。根据上一步分析的结果,如果要提取 Stream #0:2,这里的 N 就是 2。如果只有一个音轨 Stream #0:1N 就是 1。如果不确定,可以省略 -map 参数,ffmpeg 会自动选择它认为是“最佳”的音轨(通常是默认音轨)。
    • 例如,要提取中文音轨 Stream #0:2,就用 -map 0:a:1注意,ffmpeg 的流索引是从0开始算的,所以 #0:2 对应 -map 0:a:1 是不对的,应该是 #0:2 对应 -map 0:2 或者 -map 0:a:1 如果它是第二条音轨。为了避免混淆,使用 0:2 这种精确指定更安全,或者使用 0:a:1 表示第二条音频流)。最清晰的方式是指定流的绝对索引,如 -map 0:2
    • 为了简单起见,我们假设你要提取的是第二条音轨,即索引为1的音频流,使用 -map 0:a:1
  • -c:a pcm_s16le: 指定音频编码器。pcm_s16le 是生成标准 WAV 文件所用的编码,代表16位深度的无压缩音频。
  • -ar 16000: (可选,但推荐)设置音频采样率。许多语音识别模型(特别是开源模型)在 16000 Hz (16kHz) 的采样率下表现最佳。这也可以减小文件大小。如果你的源音频采样率已经是 16000Hz,ffmpeg 不会做任何事。
  • "输出音频.wav": 指定输出文件名。

步骤2:生成字幕

现在,你可以使用这个 输出音频.wav 文件和支持时间戳的语音识别工具来生成字幕。以目前非常流行的开源工具 OpenAI Whisper 为例:

bash
# 安装 whisper (如果还没安装)
# pip install -U openai-whisper

# 使用 whisper 生成字幕
whisper "输出音频.wav" --model medium --language English

运行完毕后,Whisper 会在同目录下生成 .txt, .vtt, 和 .srt 格式的字幕文件,这些文件里就包含了精确的时间戳。

或者考虑使用 stable-whisper 这个工具,它是一个基于 Whisper 的工具,可以生成更准确的字幕。

bash
# 安装 stable-whisper
pip install stable-st

# 使用 stable-whisper 生成字幕
stable-st "输出音频.wav" --model medium --language English --output_formats srt

(可选)可以使用下面这段代码来生成翻译成双语字幕

python
"""
双语字幕翻译工具
使用LLM API将英文字幕翻译成中文,并生成双语字幕文件
支持分批处理、断点续传、配置文件等功能
"""

import argparse
import os
import pickle
import re
import time
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import List

import litellm
import tiktoken
from rich.console import Console

console = Console()


@dataclass
class SubtitleBlock:
    """字幕块数据结构"""

    index: int
    start_time: str
    end_time: str
    text: str
    translated_text: str | None = None

    def to_dict(self):
        return asdict(self)

    @classmethod
    def from_dict(cls, data):
        return cls(**data)

    def __str__(self):
        if self.translated_text:
            return f"\n{self.index}\n{self.start_time} --> {self.end_time}\n{self.text}\n{self.translated_text}"
        else:
            return f"\n{self.index}\n{self.start_time} --> {self.end_time}\n{self.text}"


class SubtitleParser:
    """字幕文件解析器"""

    @staticmethod
    def parse_srt(file_path: str) -> List[SubtitleBlock]:
        """解析SRT字幕文件"""
        blocks = []

        try:
            with open(file_path, encoding="utf-8") as f:
                content = f.read()
        except UnicodeDecodeError:
            # 尝试其他编码
            with open(file_path, encoding="gbk") as f:
                content = f.read()

        # 使用正则表达式分割字幕块
        pattern = r"(\d+)\s+(\d{2}:\d{2}:\d{2},\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2},\d{3})\s+([\s\S]*?)(?=\n\d+\n|$)"
        matches = re.findall(pattern, content)

        for match in matches:
            index = int(match[0])
            start_time = match[1]
            end_time = match[2]
            text = match[3].strip()

            # 清理文本中的多余换行符
            text = re.sub(r"\n+", " ", text).strip()

            blocks.append(SubtitleBlock(index=index, start_time=start_time, end_time=end_time, text=text))

        console.print(f" {datetime.now().ctime()} -- 解析完成,共找到 {len(blocks)} 个字幕块", style="green")
        return blocks


class SubtitleTranslator:
    """字幕翻译器"""

    def __init__(self, api_key: str, api_base: str, model: str = "openai/gpt-3.5-turbo", config: dict = None):
        """初始化翻译器"""
        self.model = model
        self.api_key = api_key
        self.api_base = api_base

        self.config = config
        self.translation_cache = {}

        # 初始化token统计
        self.total_input_tokens = 0
        self.total_output_tokens = 0
        self.total_cost = 0.0

        # 初始化tiktoken编码器
        try:
            self.encoding = tiktoken.encoding_for_model(model)
        except KeyError:
            # 如果模型不在tiktoken支持列表中,使用cl100k_base编码器(GPT-4/3.5默认)
            self.encoding = tiktoken.get_encoding("cl100k_base")

        # 定义模型价格(每1000 tokens的价格,美元)
        self.model_prices = {
            "gpt-3.5-turbo": {"input": 0.0015, "output": 0.002},
            "gpt-3.5-turbo-16k": {"input": 0.003, "output": 0.004},
            "gpt-4": {"input": 0.03, "output": 0.06},
            "gpt-4-32k": {"input": 0.06, "output": 0.12},
            "gpt-4-turbo": {"input": 0.01, "output": 0.03},
            "gpt-4o": {"input": 0.005, "output": 0.015},
            "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
            "openai/gpt-3.5-turbo": {"input": 0.0015, "output": 0.002},
            "openai/gpt-3.5-turbo-16k": {"input": 0.003, "output": 0.004},
            "openai/gpt-4": {"input": 0.03, "output": 0.06},
            "openai/gpt-4-32k": {"input": 0.06, "output": 0.12},
            "openai/gpt-4-turbo": {"input": 0.01, "output": 0.03},
            "openai/gpt-4o": {"input": 0.005, "output": 0.015},
            "openai/gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
            "openrouter/openai/gpt-3.5-turbo": {"input": 0.0015, "output": 0.002},
            "openrouter/openai/gpt-3.5-turbo-16k": {"input": 0.003, "output": 0.004},
            "openrouter/openai/gpt-4": {"input": 0.03, "output": 0.06},
            "openrouter/openai/gpt-4-32k": {"input": 0.06, "output": 0.12},
            "openrouter/openai/gpt-4-turbo": {"input": 0.01, "output": 0.03},
            "openrouter/openai/gpt-4o": {"input": 0.005, "output": 0.015},
            "openrouter/openai/gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
        }

    def translate_batch(
        self,
        blocks: List[SubtitleBlock],
        batch_size: int = 10,
        progress_tracker: "ProgressTracker" = None,
        checkpoint_file: str = None,
        show_tokens: bool = True,
        eval_tokens: bool = False,
    ) -> List[SubtitleBlock]:
        """分批翻译字幕块,支持断点续传"""
        total_blocks = len(blocks)
        batch_size = min(batch_size, 100)  # 限制最大批次大小

        console.print(
            f" {datetime.now().ctime()} -- 开始翻译 {total_blocks} 个字幕块,批次大小: {batch_size}", style="green"
        )

        # 加载检查点
        if checkpoint_file and os.path.exists(checkpoint_file):
            blocks = self.load_checkpoint(blocks, checkpoint_file)
            console.print(f" {datetime.now().ctime()} -- 已加载检查点,继续翻译", style="green")
            # 统计已翻译的块数
            translated_count = sum(1 for block in blocks if block.translated_text)
            console.print(
                f" {datetime.now().ctime()} -- 已翻译 {translated_count}/{total_blocks} 个字幕块", style="green"
            )

        for i in range(0, total_blocks, batch_size):
            batch = blocks[i : i + batch_size]
            batch_num = i // batch_size + 1
            total_batches = (total_blocks + batch_size - 1) // batch_size

            # 检查批次中是否所有块都已翻译
            untranslated_batch = [block for block in batch if not block.translated_text]
            if not untranslated_batch:
                console.print(
                    f" {datetime.now().ctime()} -- 批次 {batch_num}/{total_batches} 已全部翻译,跳过", style="green"
                )
                continue

            console.print(
                f" {datetime.now().ctime()} -- 正在处理批次 {batch_num}/{total_batches} ({len(untranslated_batch)} 个字幕块待翻译)",
                style="green",
            )

            # 翻译当前批次
            translated_batch = self._translate_single_batch_with_retry(untranslated_batch, show_tokens, eval_tokens)

            # 更新字幕块
            for j, block in enumerate(untranslated_batch):
                if j < len(translated_batch):
                    block.translated_text = translated_batch[j]

            # 更新进度
            if progress_tracker:
                progress_tracker.update(len(batch))

            # 保存检查点
            if checkpoint_file:
                self.save_checkpoint(blocks, checkpoint_file)

            # 添加延迟避免API限制
            if i + batch_size < total_blocks:
                delay = self.config.get("rate_limit_delay", 1.0)
                time.sleep(delay)

        # 输出token统计信息
        self._print_token_statistics(show_tokens=show_tokens)

        return blocks

    def _translate_single_batch_with_retry(
        self, blocks: List[SubtitleBlock], show_tokens: bool = True, eval_tokens: bool = False
    ) -> List[str]:
        """带重试机制的单个批次翻译"""
        max_retries = self.config.get("max_retries", 3)
        retry_delay = self.config.get("retry_delay", 1.0)

        for attempt in range(max_retries):
            try:
                return self._translate_single_batch(blocks, show_tokens, eval_tokens)
            except Exception as e:
                console.print(
                    f" {datetime.now().ctime()} -- 翻译失败 (尝试 {attempt + 1}/{max_retries}): {e}", style="yellow"
                )
                if attempt < max_retries - 1:
                    time.sleep(retry_delay)
                else:
                    console.print(f" {datetime.now().ctime()} -- 翻译失败,已达到最大重试次数", style="red")
                    return [""] * len(blocks)

    def _translate_single_batch(
        self, blocks: List[SubtitleBlock], show_tokens: bool = True, eval_tokens: bool = False
    ) -> List[str]:
        """翻译单个批次"""
        texts_to_translate = []
        for block in blocks:
            texts_to_translate.append(f"{block.index}. {block.text}")

        combined_text = "\n".join(texts_to_translate)

        # 构建完整的prompt
        system_prompt = """你是一个专业的字幕翻译助手。请将以下英文字幕翻译成中文。

    翻译要求:
    1. 保持原文的意思和语调
    2. 翻译要自然流畅,符合中文表达习惯
    3. 保持字幕的简洁性,适合快速阅读
    4. 专业术语要准确翻译
    5. 每行翻译前要保留序号
    6. 如果原文包含特殊符号或格式,请保持

    请直接返回翻译结果,每行一个翻译,格式为:序号. 中文翻译"""

        # 计算输入tokens
        input_tokens = len(self.encoding.encode(system_prompt + "\n" + combined_text))
        self.total_input_tokens += input_tokens

        if not eval_tokens:
            with console.status("大模型翻译中...", spinner="monkey"):
                response = litellm.completion(
                    model=self.model,
                    api_key=self.api_key,
                    base_url=self.api_base,
                    messages=[
                        {
                            "role": "system",
                            "content": system_prompt,
                        },
                        {"role": "user", "content": combined_text},
                    ],
                    temperature=0.3,
                )
            translated_text = response.choices[0].message.content.strip()
            # 计算输出tokens和成本
            output_tokens = len(self.encoding.encode(translated_text))
        else:
            translated_text = "1. 由于评估token数量,没有进行翻译\n" * len(blocks)
            output_tokens = 0
        self.total_output_tokens += output_tokens

        # 计算成本
        model_price = self.model_prices.get(self.model, {"input": 0.0015, "output": 0.002})
        input_cost = (input_tokens / 1000) * model_price["input"]
        output_cost = (output_tokens / 1000) * model_price["output"]
        batch_cost = input_cost + output_cost
        self.total_cost += batch_cost

        # 显示当前批次的token统计
        if show_tokens:
            if eval_tokens:
                console.print(
                    f" {datetime.now().ctime()} -- 批次tokens: 输入{input_tokens}, 成本${batch_cost:.4f}",
                    style="cyan",
                )
            else:
                console.print(
                    f" {datetime.now().ctime()} -- 批次tokens: 输入{input_tokens}, 输出{output_tokens}, 成本${batch_cost:.4f}",
                    style="cyan",
                )

        # 解析翻译结果
        translations = []
        lines = translated_text.split("\n")

        for line in lines:
            line = line.strip()
            if line and re.match(r"^\d+\.", line):
                # 提取翻译文本(去掉序号)
                translation = re.sub(r"^\d+\.\s*", "", line)
                translations.append(translation)

        # 确保翻译数量与原文数量一致
        while len(translations) < len(blocks):
            translations.append("")

        return translations[: len(blocks)]

    def save_checkpoint(self, blocks: List[SubtitleBlock], checkpoint_file: str):
        """保存检查点"""
        try:
            checkpoint_data = {"blocks": [block.to_dict() for block in blocks], "timestamp": time.time()}
            with open(checkpoint_file, "wb") as f:
                pickle.dump(checkpoint_data, f)
        except Exception as e:
            console.print(f" {datetime.now().ctime()} -- 保存检查点失败: {e}", style="red")

    def load_checkpoint(self, original_blocks: List[SubtitleBlock], checkpoint_file: str) -> List[SubtitleBlock]:
        """加载检查点"""
        try:
            with open(checkpoint_file, "rb") as f:
                checkpoint_data = pickle.load(f)

            # 重建字幕块
            checkpoint_blocks = []
            for block_data in checkpoint_data["blocks"]:
                block = SubtitleBlock.from_dict(block_data)
                checkpoint_blocks.append(block)

            return checkpoint_blocks
        except Exception as e:
            console.print(f" {datetime.now().ctime()} -- 加载检查点失败: {e}", style="red")
            return original_blocks

    def _print_token_statistics(self, show_tokens: bool = True):
        """输出token统计信息"""
        if show_tokens:
            console.print(f" {datetime.now().ctime()} -- 总输入tokens: {self.total_input_tokens}")
            console.print(f" {datetime.now().ctime()} -- 总输出tokens: {self.total_output_tokens}")
            console.print(f" {datetime.now().ctime()} -- 总成本: {self.total_cost:.4f} 美元")


class SubtitleWriter:
    """字幕文件写入器"""

    @staticmethod
    def write_bilingual_srt(blocks: List[SubtitleBlock], output_path: str):
        """写入双语SRT文件"""
        with open(output_path, "w", encoding="utf-8") as f:
            for block in blocks:
                f.write(f"{block.index}\n")
                f.write(f"{block.start_time} --> {block.end_time}\n")
                f.write(f"{block.text}\n")
                if block.translated_text:
                    f.write(f"{block.translated_text}\n")
                f.write("\n")

        console.print(f" {datetime.now().ctime()} -- 双语字幕文件已保存到: {output_path}", style="green")

    @staticmethod
    def write_chinese_srt(blocks: List[SubtitleBlock], output_path: str):
        """写入中文字幕文件"""
        with open(output_path, "w", encoding="utf-8") as f:
            for block in blocks:
                if block.translated_text:
                    f.write(f"{block.index}\n")
                    f.write(f"{block.start_time} --> {block.end_time}\n")
                    f.write(f"{block.translated_text}\n")
                    f.write("\n")

        console.print(f" {datetime.now().ctime()} -- 中文字幕文件已保存到: {output_path}", style="green")

    @staticmethod
    def write_ass_bilingual(blocks: List[SubtitleBlock], output_path: str):
        """写入ASS双语字幕文件"""
        ass_header = """[Script Info]
Title: Bilingual Subtitles
ScriptType: v4.00+
WrapStyle: 1
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.601

[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,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Chinese,Arial,18,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,30,1

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

        with open(output_path, "w", encoding="utf-8") as f:
            f.write(ass_header)

            for block in blocks:
                # 转换时间格式
                start_time = SubtitleWriter._srt_to_ass_time(block.start_time)
                end_time = SubtitleWriter._srt_to_ass_time(block.end_time)

                # 写入英文字幕
                f.write(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{block.text}\n")

                # 写入中文字幕
                if block.translated_text:
                    f.write(f"Dialogue: 0,{start_time},{end_time},Chinese,,0,0,0,,{block.translated_text}\n")

        console.print(f" {datetime.now().ctime()} -- ASS双语字幕文件已保存到: {output_path}", style="green")

    @staticmethod
    def _srt_to_ass_time(srt_time: str) -> str:
        """将SRT时间格式转换为ASS时间格式"""
        # SRT: 00:00:00,000 -> ASS: 0:00:00.00
        time_part, ms_part = srt_time.split(",")
        h, m, s = time_part.split(":")
        return f"{int(h)}:{m}:{s}.{ms_part[:2]}"


class ProgressTracker:
    """进度跟踪器"""

    def __init__(self, total_blocks: int):
        self.total_blocks = total_blocks
        self.processed_blocks = 0
        self.start_time = time.time()

    def update(self, count: int = 1):
        """更新进度"""
        self.processed_blocks += count
        elapsed = time.time() - self.start_time
        progress = (self.processed_blocks / self.total_blocks) * 100

        if self.processed_blocks > 0:
            eta = (elapsed / self.processed_blocks) * (self.total_blocks - self.processed_blocks)
            console.print(
                f"进度: {progress:.1f}% ({self.processed_blocks}/{self.total_blocks}) "
                f"已用时: {elapsed:.1f}s 预计剩余: {eta:.1f}s"
            )


def main():
    parser = argparse.ArgumentParser(description="双语字幕翻译工具")
    parser.add_argument("input_file", help="输入的SRT字幕文件路径")
    parser.add_argument("--api-key", help="LLM API密钥")
    parser.add_argument("--api-base", help="LLM API基地址", default="https://api.openai.com/v1")
    parser.add_argument("--model", help="使用的OpenAI模型", default="gpt-3.5-turbo")
    parser.add_argument("--batch-size", type=int, help="批处理大小", default=10)
    parser.add_argument("--output-dir", help="输出目录", default="translated_subtitles")
    parser.add_argument("--checkpoint", help="检查点文件路径")
    parser.add_argument("--rate_limit_delay", type=float, help="API请求间隔时间", default=1.0)
    parser.add_argument("--max_retries", type=int, help="最大重试次数", default=3)
    parser.add_argument("--retry_delay", type=float, help="重试间隔时间", default=1.0)
    parser.add_argument(
        "--format",
        choices=["srt", "ass", "both"],
        default="srt",
        help="输出格式:srt(双语SRT), ass(ASS双语), both(两种格式)",
    )
    parser.add_argument("--bilingual-only", action="store_true", help="只生成双语字幕文件")
    parser.add_argument("--show-tokens", action="store_true", help="显示token统计信息")
    parser.add_argument("--eval-tokens", action="store_true", help="评估token数量")
    args = parser.parse_args()

    console.rule("[bold blue]双语字幕翻译工具")

    # 检查输入文件
    if not os.path.exists(args.input_file):
        console.print(f"输入文件不存在: {args.input_file}", style="red")
        return

    # 获取API密钥
    if not args.api_key:
        console.print("未提供LLM API密钥,请在配置文件中设置或使用--api-key参数", style="red")
        return

    # 创建输出目录
    os.makedirs(args.output_dir, exist_ok=True)

    # 解析字幕文件
    with console.status("正在解析字幕文件...", spinner="monkey"):
        parser = SubtitleParser()
        blocks = parser.parse_srt(args.input_file)

    # 初始化翻译器
    translator = SubtitleTranslator(
        args.api_key,
        args.api_base,
        args.model,
        {
            "rate_limit_delay": args.rate_limit_delay,
            "max_retries": args.max_retries,
            "retry_delay": args.retry_delay,
        },
    )

    # 初始化进度跟踪器
    progress = ProgressTracker(len(blocks))

    # 翻译字幕
    translated_blocks = translator.translate_batch(
        blocks, args.batch_size, progress, args.checkpoint, args.show_tokens, args.eval_tokens
    )

    # 生成输出文件名
    input_path = Path(args.input_file)
    base_name = input_path.stem

    # 写入字幕文件
    if args.format in ["srt", "both"]:
        # 双语SRT文件
        bilingual_output = os.path.join(args.output_dir, f"{base_name}_bilingual.srt")
        SubtitleWriter.write_bilingual_srt(translated_blocks, bilingual_output)

        # 中文字幕文件
        if not args.bilingual_only:
            chinese_output = os.path.join(args.output_dir, f"{base_name}_chinese.srt")
            SubtitleWriter.write_chinese_srt(translated_blocks, chinese_output)

    if args.format in ["ass", "both"]:
        # ASS双语字幕文件
        ass_output = os.path.join(args.output_dir, f"{base_name}_bilingual.ass")
        SubtitleWriter.write_ass_bilingual(translated_blocks, ass_output)

    console.print(f" {datetime.now().ctime()} -- 翻译完成!", style="green")


if __name__ == "__main__":
    main()

步骤3:合并字幕与视频

现在手上有了原始视频文件(比如 你的视频文件.mkv)和生成的字幕文件(比如 输出字幕.srt),接下来,将字幕与视频合并,ffmpeg 同样是完成这个任务的完美工具。

合并字幕主要有两种方式:

  1. 软字幕 (Softsubs):将字幕文件作为一条独立的轨道封装进视频容器(如 MKV)中。

    • 优点:速度极快(因为不重新编码视频),不损失任何视频质量,观众可以在播放器中自由选择开启、关闭或切换字幕。这是最推荐的方式。
    • 缺点:需要播放器支持。不过,现代主流播放器(如 VLC, PotPlayer, MPV, Plex)都完美支持。
  2. 硬字幕 (Hardsubs):将字幕“烧录”或“压制”进视频画面中,使其成为视频图像的一部分。

    • 优点:在任何设备、任何播放器上都能显示,因为字幕已经和画面融为一体。
    • 缺点:过程很慢(需要重新编码整个视频),会造成视频质量损失,且字幕是永久性的,无法关闭。

除非你有特殊需求(例如,要在不支持字幕轨道的旧设备上播放,或上传到某些强制转码的社交平台),否则永远首选软字幕。这边以软字幕为例,这种方法本质上是把视频、音频和字幕这三个独立的“素材”打包到一个新的 MKV 文件里。

下面的命令会将原始视频的所有流(视频、所有音轨)和新的字幕流一起复制到新文件中。

bash
ffmpeg -i "你的视频文件.mkv" -i "输出字幕.srt" -c copy -map 0 -map 1 "带字幕的视频.mkv"

命令详解

  • -i "你的视频文件.mkv": 指定第一个输入文件(你的视频)。
  • -i "输出字幕.srt": 指定第二个输入文件(你的字幕)。
  • -c copy (或 -codec copy): 核心参数。告诉 ffmpeg 直接“复制”所有流,不进行任何重新编码。这就是为什么它速度飞快且无损质量。
  • -map 0: 映射第一个输入文件(你的视频文件.mkv)的所有流到输出文件。这能确保原始视频里的多条音轨等信息不会丢失。
  • -map 1: 映射第二个输入文件(输出字幕.srt)的所有流到输出文件。
  • "带字幕的视频.mkv": 输出文件名。MKV 是支持多轨道字幕的绝佳容器格式。

为了让字幕在播放器里显示得更友好(例如,标记为“中文”),我们可以添加一些元数据。下面的命令会为字幕轨道添加语言和标题信息。

bash
ffmpeg -i "你的视频文件.mkv" -i "输出字幕.srt" -c copy -map 0 -map 1 -c:s mov_text -metadata:s:s:0 language=eng -metadata:s:s:0 title="English (AI generated)" "带字幕的视频.mkv"

新增参数详解

  • -c:s mov_text: 将输入的 SRT 格式字幕转换为 mov_text 格式,它在 MKV 容器中有更好的兼容性。虽然 ffmpeg 通常会自动处理,但明确指定更稳妥。
  • -metadata:s:s:0 language=eng:
    • -metadata: 设置元数据。
    • :s:s:0: s:s 指的是字幕流(subtitle stream),:0 指的是输出文件中的第一个字幕流(因为我们只添加了一个)。
    • language=eng: 将该字幕轨道的语言标记为“英文”(使用 ISO 639-2 标准代码)。
  • -metadata:s:s:0 title="English (AI generated)": 为这个字幕轨道设置一个标题,在播放器的字幕选择菜单中会显示这个标题。

现在,用 VLC 或其他播放器打开 带字幕的视频.mkv,你就可以在字幕菜单里看到一个名为“English (AI generated)”的轨道了!