一、项目概述

在海外视频本地化、自媒体素材处理、外文教学视频汉化等场景中,视频字幕翻译、内嵌压制是高频刚需工作。传统人工翻译效率低下,在线翻译工具存在文件隐私泄露、批量处理受限、水印收费等问题。
本文介绍一套基于PHP、FFmpeg、OpenCC、本地大模型搭建的全自动视频字幕翻译系统,可实现本地MKV视频批量处理、字幕提取、繁简转换、AI智能翻译、字幕自动换行、视频转码内嵌字幕全流程自动化。整套方案纯本地运行,无网络数据上传、支持批量递归处理文件夹、适配上下文翻译优化,完美适配Windows本地离线视频汉化场景。
核心能力清单:
  • 批量递归遍历文件夹,自动识别MKV视频文件
  • 基于FFmpeg精准提取视频内所有封装字幕流(英文、繁体中文等)
  • OpenCC高速繁体字幕转简体中文
  • 本地大模型上下文智能翻译英文字幕,解决断句、语义偏差问题
  • 字幕文本清洗、超长自动换行,适配视频播放规范
  • FFmpeg视频转码压缩、字幕硬压制,输出通用MP4格式
  • 任务去重机制,避免重复处理已完成视频

二、技术栈整体架构

整套系统采用PHP脚本驱动+开源工具赋能+本地AI推理的轻量化架构,无需搭建复杂服务,单脚本即可运行,适配Windows本地环境,各组件分工明确、解耦清晰。

2.1 核心组件说明

  • PHP 8.2+:核心脚本载体,负责流程调度、文件处理、AI接口调用、逻辑判断,语法简洁且适配Windows命令行运行
  • FFmpeg/FFprobe:音视频处理核心,实现字幕流探测、字幕提取、视频转码、字幕压制、分辨率压缩
  • OpenCC:开源繁简转换工具,精准完成繁体字幕、港台用语到简体中文的转换
  • 本地大模型(Ollama):基于本地部署的混元翻译大模型,离线完成字幕文本翻译,支持上下文关联优化翻译质量

2.2 整体工作流程

文件夹遍历 → 视频文件筛选 → 字幕流探测提取 → 字幕分类(英文/繁体/简体)→ 繁简转换/AI翻译 → 字幕清洗格式化 → 视频转码+字幕压制 → 成品MP4输出,全程自动化无人工干预。

三、核心功能模块详解

3.1 批量文件遍历与任务去重模块

脚本支持递归遍历多级文件夹,自动扫描目标目录下所有MKV格式视频,同时内置完善的去重机制:检测到对应目录已存在翻译完成的MP4文件、已生成的字幕文件时,自动跳过当前任务,避免重复运算浪费资源。
通过路径还原算法,完美复刻原文件夹目录结构,翻译后的视频和字幕文件会同步归档,保证文件层级清晰,方便批量素材管理。

3.2 多类型字幕智能提取与分类

依托FFprobe探测视频所有字幕流信息,精准获取每个字幕流的语言、标签、标题等元数据,区分普通英文字幕、SDH手语字幕、简体字幕、繁体字幕(适配CHT、HongKong、Traditional等标识)。
字幕筛选优先级:优先提取普通英文字幕,无普通字幕则启用SDH字幕;优先复用已有简体字幕,无简体字幕则转换繁体字幕,最大化利用视频原生字幕,减少AI翻译工作量,提升效率。

3.3 OpenCC繁简精准转换

针对海外视频常见的繁体中文、港台异体字字幕,调用OpenCC官方转换规则(t2s.json简繁映射模板),实现标准化繁体转简体。相比代码字符替换,OpenCC支持词组级转换,可精准修正“裡/里”“後/后”等异体字,保证字幕规范性。

3.4 带上下文的AI字幕翻译核心

这是本方案区别于普通逐行翻译脚本的核心优化点。普通字幕逐行独立翻译容易出现语义断裂、指代不明、语句不通顺的问题,本脚本支持自定义上下文行数。
翻译每一句字幕时,自动拼接前文、后文字幕内容作为背景上下文,通过精准Prompt约束大模型输出规则:仅返回翻译结果、保持行数一致、无多余解释。同时内置翻译失败兜底机制,AI请求异常时自动保留原文,避免字幕空白、错乱。
模型采用本地部署的混元翻译大模型,通过Ollama接口调用,全程离线推理,视频字幕素材不会上传外网,彻底保障隐私安全。

3.5 字幕文本清洗与自适应换行

针对原生字幕中的特殊符号、音效标注、标签冗余问题,脚本内置完善的文本清洗逻辑:自动过滤♪、#、*等符号,清除[]、()、{}内的音效、场景标注,去除无效占位符。
同时实现中英文混合自适应换行算法:根据字符显示宽度(中文2字符、英文1字符)计算行宽,以50字符为最大展示宽度,在标点符号处智能换行,避免字幕超长溢出、截断语句,完美适配主流播放器显示规范。

3.6 视频智能转码与字幕压制

通过FFmpeg实现视频轻量化转码,自动适配分辨率:大于1920*1080的视频自动压缩为1080P,小分辨率视频保留原始尺寸,采用lanczos缩放算法保证画质清晰度。
音频统一编码为AAC 192K、48KHz标准格式,兼容全平台播放器。支持字幕硬压制,自定义字体为楷体、字号20,保证字幕美观清晰,同时开启faststart快速启动模式,优化视频加载速度。

四、环境部署与配置

4.1 依赖环境安装

整套环境基于Windows平台搭建,需提前安装配置以下工具:
  1. PHP 8.2+:配置独立运行目录,开启curl扩展(必备,用于AI接口请求)
  2. FFmpeg:完整编译包,配置bin目录可执行文件路径,用于音视频处理
  3. OpenCC:带配置文件的完整编译包,保留t2s.json繁简转换模板
  4. Ollama+翻译大模型:本地部署modelscope.cn/Tencent-Hunyuan/Hy-MT2-7B-GGUF翻译模型,开启本地11434端口服务

4.2 核心配置参数

脚本所有配置集中在文件头部,可按需自定义:
  • 视频源目录、输出目录:自定义机翻视频前后存储路径
  • 第三方工具路径:精准配置ffmpeg、ffprobe、opencc可执行文件绝对路径
  • AI参数:自定义翻译模型、上下文行数、温度系数、生成长度等
  • 字幕样式:可修改字体、字号、最大行宽等展示参数

4.3 运行命令

通过PHP命令行直接执行脚本,无需Web服务:
C:\dev\php\php-8.2.4\php.exe -f fy.php
运行后全程日志可视化,实时展示当前翻译进度、命令执行状态、耗时及结果,方便排查异常。

五、关键技术优化亮点

5.1 离线本地化运行,隐私零泄露

区别于在线翻译工具,整套系统所有视频素材、字幕文本、翻译推理均在本地设备完成,无外网上传行为,适合处理私密素材、版权视频,规避数据泄露风险。

5.2 上下文关联翻译,大幅提升字幕质量

传统逐行翻译割裂语义,本脚本通过前后文上下文拼接,让AI理解完整对话场景,有效修正直译错误、语义偏差,解决代词翻译混乱、语句不通顺的问题,字幕可读性大幅提升。

5.3 全链路容错机制

从文件读取、命令执行、AI请求、文件写入全流程配置容错处理:命令执行失败日志记录、AI请求超时重试兜底、文件不存在判断、空字幕过滤、编码异常修复,保证批量任务不会因单个文件异常中断。

5.4 智能化资源复用

自动检测已有字幕、成品视频,跳过已完成任务;优先复用原生简体/繁体字幕,仅无中文字幕时才启动AI翻译,大幅降低算力消耗,提升批量处理效率。

六、适用场景与扩展方向

6.1 核心适用场景

  • 自媒体海外视频汉化、外文素材批量处理
  • 教学视频、纪录片、影视片段离线字幕翻译压制
  • 企业私密外文培训视频本地化处理
  • 批量MKV视频格式转换+字幕汉化一站式处理

6.2 可扩展优化方向

  • 增加AI翻译重试机制,针对超时、空结果自动重试,进一步提升成功率
  • 新增多语言互译,适配日语、韩语等多语种字幕翻译
  • 添加任务进度保存、断点续跑功能,支持超大视频批量处理
  • 开发简易GUI界面,替代命令行操作,降低使用门槛
  • 支持音频识别翻译,适配无内置字幕的视频文件

七、总结

这套基于PHP+FFmpeg+本地大模型的视频字幕翻译方案,以轻量化、离线化、自动化、高质量为核心优势,用简单的PHP脚本整合成熟的开源音视频工具与AI推理能力,低成本实现了专业级视频字幕汉化流水线。
无需服务器部署、无需高额API费用、无隐私风险,完美适配个人、小团队的批量视频本地化需求,相比人工翻译和在线工具,效率提升数十倍,同时保证了字幕翻译质量和素材安全性,是Windows平台离线视频汉化的优质轻量化解决方案。

八、完整代码

<?php

/**
 * 需要下载 php、ffmpeg、OpenCC
 * php:C:\dev\php\php-8.2.4(PHP 8.3 亦可)
 * ffmpeg:C:\dev\ffmpeg
 * OpenCC:C:\dev\OpenCC
 * 运行方法:C:\dev\php\php-8.2.4\php.exe -f fy.php
 */

// --------------------------------------------------------------
// 可配置路径
// --------------------------------------------------------------

// mkv视频所在文件夹位置
$translate_video_dir = 'C:\迅雷下载\机翻视频前';
// 翻译后的视频所在文件夹,包含翻译后的视频以及提取的字幕、翻译的字幕
$translate_dir = 'C:\迅雷下载\机翻视频后';

// 外部程序全路径(Windows 下建议写全路径,避免 PATH 问题)
$ffmpeg_bin  = 'C:\dev\ffmpeg\bin\ffmpeg.exe';
$ffprobe_bin = 'C:\dev\ffmpeg\bin\ffprobe.exe';
$opencc_bin  = 'C:\dev\OpenCC\build\bin\opencc.exe';
$t2s         = 'C:\dev\OpenCC\build\share\opencc\t2s.json';

// AI 翻译服务
$ai_api   = 'http://localhost:11434/api/generate';
$ai_model = 'modelscope.cn/Tencent-Hunyuan/Hy-MT2-7B-GGUF:Q4_K_M';

// 逐行翻译时的上下文行数(前后各取几行)
$translate_context_size = 1;

date_default_timezone_set('Asia/Shanghai');

// --------------------------------------------------------------
// 日志函数
// --------------------------------------------------------------

/**
 * 输出日志(带时间戳)
 */
function log_msg(string $msg): void
{
    echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
}

/**
 * 实时执行外部命令(边执行边输出到 stdout)
 * @param string $cmd 完整命令行
 * @param bool $show_output 是否显示输出
 * @return int 返回码
 */
function run_cmd(string $cmd, bool $show_output = true): int
{
    log_msg('执行命令:' . $cmd);
    $start = microtime(true);

    if ($show_output) {
        passthru($cmd . ' 2>&1', $code);
    } else {
        shell_exec($cmd . ' 2>&1');
        $code = 0;
    }

    $cost = round(microtime(true) - $start, 2);
    log_msg("命令结束,返回码:{$code},耗时:{$cost}s");
    return (int)$code;
}

/**
 * 执行命令并捕获输出(不实时显示),返回 [code, stdout, stderr]
 */
function run_cmd_capture(string $cmd): array
{
    $descriptors = [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];
    $process = proc_open($cmd, $descriptors, $pipes);
    if (!is_resource($process)) {
        return ['code' => -1, 'stdout' => '', 'stderr' => ''];
    }
    fclose($pipes[0]);
    $stdout = stream_get_contents($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    $code = proc_close($process);
    return [
        'code'   => $code,
        'stdout' => $stdout !== false ? $stdout : '',
        'stderr' => $stderr !== false ? $stderr : '',
    ];
}

// --------------------------------------------------------------

log_msg('========== 翻译任务开始 ==========');

translate_dir($translate_video_dir);

log_msg('========== 翻译任务结束 ==========');

/**
 * 翻译文件夹内的视频
 */
function translate_dir($video_dir)
{
    global $translate_video_dir, $translate_dir;
    log_msg('翻译视频文件夹:' . $video_dir);
    $files = scandir($video_dir);
    if ($files === false) {
        log_msg('无法读取目录:' . $video_dir);
        return;
    }
    foreach ($files as $file) {
        if ('.' === $file || '..' === $file) {
            continue;
        }
        if (is_dir($video_dir . '/' . $file)) {
            translate_dir($video_dir . '/' . $file);
            continue;
        }
        if (!str_ends_with($file, '.mkv')) {
            continue;
        }
        log_msg('----------------------------------------');
        $file_name = pathinfo($file, PATHINFO_FILENAME);
        $mkv_file = realpath($video_dir . '/' . $file);
        if ($mkv_file === false) {
            log_msg('无法解析文件路径:' . $video_dir . '/' . $file);
            continue;
        }
        $path = trim(str_replace($translate_video_dir, '', $video_dir), '\\');
        $path = trim($path, '/');
        $relate_dir = $translate_dir;
        if (!empty($path)) {
            $relate_dir = $translate_dir . '/' . $path;
        }
        $mp4_file = $relate_dir . '/' . $file_name . '.mp4';
        if (file_exists($mp4_file)) {
            log_msg('视频已经翻译,无需再次翻译:' . $mp4_file);
            continue;
        }
        $srt_dir = $relate_dir . '/字幕/' . $file_name;
        $srt_file = $srt_dir . '/' . $file_name . '.srt';
        if (!file_exists($srt_file)) {
            log_msg('开始提取字幕');
            get_srt($mkv_file, $srt_dir);
            translate_video($srt_dir, $srt_file);
        }
        mkv_to_mp4($mkv_file, $srt_file, $mp4_file);
    }
}

/**
 * 获取最终中文字幕文件
 */
function translate_video($srt_dir, $srt_file)
{
    if (file_exists($srt_file) && filesize($srt_file) > 0) {
        log_msg('字幕文件已存在,无需翻译:' . $srt_file);
        return;
    }
    if (!is_dir($srt_dir)) {
        log_msg('字幕文件夹不存在:' . $srt_dir);
        return;
    }
    $files = scandir($srt_dir);
    if ($files === false) {
        log_msg('无法读取字幕目录:' . $srt_dir);
        return;
    }

    $en_normal_files = [];
    $en_sdh_files = [];
    $chi_srt_file = '';
    $chi_traditional_srt_file = '';

    foreach ($files as $file) {
        if ('.' === $file || '..' === $file) {
            continue;
        }
        if (preg_match('/_eng/i', $file)) {
            if (preg_match('/_SDH/i', $file)) {
                $en_sdh_files[] = $srt_dir . '/' . $file;
            } else {
                $en_normal_files[] = $srt_dir . '/' . $file;
            }
        } else if (preg_match('/_chi/i', $file)) {
            if (preg_match('/(Traditional|Hong[ _]?Kong|CHT|繁)/iu', $file)) {
                $chi_traditional_srt_file = $srt_dir . '/' . $file;
            } else {
                $chi_srt_file = $srt_dir . '/' . $file;
            }
        }
    }

    $en_candidates = !empty($en_normal_files) ? $en_normal_files : $en_sdh_files;
    $en_srt_file = '';
    $en_srt_file_size = 0;
    foreach ($en_candidates as $f) {
        $sz = @filesize($f);
        if ($sz !== false && $sz > $en_srt_file_size) {
            $en_srt_file = $f;
            $en_srt_file_size = $sz;
        }
    }

    if (!empty($chi_srt_file)) {
        log_msg('简体字幕文件已存在,直接翻译:' . $chi_srt_file);
        convert_with_opencc($chi_srt_file, $srt_file);
        wrap_srt_file($srt_file);
    } else if (!empty($chi_traditional_srt_file)) {
        log_msg('繁体字幕文件已存在,直接翻译:' . $chi_traditional_srt_file);
        convert_with_opencc($chi_traditional_srt_file, $srt_file);
        wrap_srt_file($srt_file);
    } else if (!empty($en_srt_file)) {
        log_msg('英文字幕文件已存在,开始翻译:' . $en_srt_file);
        $srtArr = parse_srt_file($en_srt_file);

        $textPositions = [];
        $allTexts = [];
        foreach ($srtArr as $k => $line) {
            $line = trim($line);
            if ($line === '' || is_numeric($line) || preg_match('/^\d{2}:\d{2}:\d{2},\d{3}/', $line)) {
                continue;
            }
            $textPositions[] = $k;
            $allTexts[] = $line;
        }

        log_msg('待翻译字幕行数:' . count($allTexts));

        $translatedTexts = translate_texts_line_by_line($allTexts);

        $out_data = [];
        $textIdx = 0;
        foreach ($srtArr as $k => $line) {
            $line = trim($line);
            if (isset($textPositions[$textIdx]) && $textPositions[$textIdx] === $k) {
                $original = $allTexts[$textIdx];
                $translated = $translatedTexts[$textIdx] ?? $original;
                if ($translated === '') {
                    $translated = $original;
                }
                $wrapped = wrap_subtitle_line($translated);
                foreach (explode("\n", $wrapped) as $wl) {
                    $out_data[] = $wl;
                }
                $textIdx++;
            } else {
                $out_data[] = $line;
            }
        }

        $res = file_put_contents($srt_file, implode("\n", $out_data));
        if ($res !== false) {
            log_msg('字幕翻译成功:' . $srt_file);
        } else {
            log_msg('字幕翻译失败:' . $srt_file);
        }
    } else {
        log_msg('未找到任何可用的字幕文件');
    }
}

/**
 * 用 OpenCC 做繁转简
 */
function convert_with_opencc($in_file, $out_file)
{
    global $opencc_bin, $t2s;
    if (!file_exists($opencc_bin)) {
        log_msg('OpenCC 不存在:' . $opencc_bin);
        return false;
    }
    $cmd = escapeshellarg($opencc_bin)
        . ' -i ' . escapeshellarg($in_file)
        . ' -o ' . escapeshellarg($out_file)
        . ' -c ' . escapeshellarg($t2s);
    run_cmd($cmd, true);
    if (!file_exists($out_file) || filesize($out_file) === 0) {
        log_msg('OpenCC 转换失败');
        return false;
    }
    return true;
}

function parse_srt_file($srt_file)
{
    if (!file_exists($srt_file)) {
        return [];
    }
    $content = file_get_contents($srt_file);
    if ($content === false) {
        return [];
    }
    if (str_starts_with($content, "\xEF\xBB\xBF")) {
        $content = substr($content, 3);
    }
    $content = str_replace(["\r\n", "\r"], "\n", $content);
    $lines = explode("\n", $content);
    $subtitles = [];
    $k = 0;
    $expectIndex = true;
    foreach ($lines as $line) {
        $line = preg_replace('/\p{Z}+/u', ' ', $line);
        $line = trim($line);
        if ($line === '') {
            $expectIndex = true;
            continue;
        }
        if ($expectIndex && preg_match('/^\d+$/', $line)) {
            $k = (int)$line;
            $subtitles[$k] = [
                'k' => $k,
                'time' => '',
                'content' => ''
            ];
            $expectIndex = false;
        } else if (preg_match('/^\d{2}:\d{2}:\d{2},\d{3}/', $line)) {
            if ($k > 0) {
                $subtitles[$k]['time'] = $line;
            }
            $expectIndex = false;
        } else {
            if ($k <= 0) {
                continue;
            }
            $expectIndex = false;
            $line = trimline($line);
            if ($line === '') {
                continue;
            }
            $sep = ' ';
            if (str_starts_with($line, '-')) {
                $sep = PHP_EOL;
            }
            $content = $subtitles[$k]['content'] ?? '';
            if ($content !== '') {
                $content .= $sep . $line;
            } else {
                $content = $line;
            }
            $subtitles[$k]['content'] = $content;
        }
    }
    foreach ($subtitles as $k => $v) {
        $v['content'] = trimline($v['content']);
        if ($v['content'] === '') {
            unset($subtitles[$k]);
            continue;
        }
        $subtitles[$k]['content'] = $v['content'];
    }
    $data = [];
    $k = 1;
    foreach ($subtitles as $v) {
        $data[] = $k;
        $data[] = $v['time'];
        $contents = explode(PHP_EOL, $v['content']);
        foreach ($contents as $content) {
            $data[] = $content;
        }
        $data[] = '';
        $k++;
    }
    return $data;
}

function trimline($line)
{
    $line = strip_tags($line);
    $line = str_replace(['♪', '#', '*'], '', $line);
    $line = preg_replace('/\[.*?\]/iu', '', $line);
    $line = preg_replace('/\{.*?\}/iu', '', $line);
    $line = preg_replace('/\(.*?\)/iu', '', $line);
    $line = trim($line);
    if ($line === '-') {
        return '';
    }
    return $line;
}

/**
 * 导出视频文件所有字幕文件
 */
function get_srt($mkv_file, $srt_dir)
{
    global $ffprobe_bin, $ffmpeg_bin;
    log_msg('提取视频字幕:' . $mkv_file);

    if (!file_exists($ffprobe_bin) || !file_exists($ffmpeg_bin)) {
        log_msg('ffmpeg/ffprobe 不存在,请检查配置');
        return;
    }

    $cmd = escapeshellarg($ffprobe_bin)
        . ' -v error -select_streams s'
        . ' -show_entries stream=index:stream_tags=language,title'
        . ' -of json ' . escapeshellarg($mkv_file);
    log_msg('执行命令:' . $cmd);
    $r = run_cmd_capture($cmd);
    $probe_output = trim($r['stdout']);
    if ($probe_output === '') {
        log_msg('获取视频字幕信息失败(无输出)');
        if ($r['stderr'] !== '') {
            echo $r['stderr'] . PHP_EOL;
        }
        return;
    }
    $data = json_decode($probe_output, true);
    if (!is_array($data) || empty($data['streams'])) {
        log_msg('未找到字幕流或 JSON 解析失败');
        return;
    }

    foreach ($data['streams'] as $v) {
        if (!isset($v['index'])) {
            continue;
        }
        $index = (int)$v['index'];
        $lang = $v['tags']['language'] ?? 'unknown';
        $lang = preg_replace('/[^\p{L}\p{N}_-]+/u', '_', $lang);
        $srt_file = $srt_dir . '/' . $index . '_' . $lang;
        if (!empty($v['tags']['title'])) {
            $title = preg_replace('/[^\p{L}\p{N}_-]+/u', '_', $v['tags']['title']);
            $srt_file .= '_' . $title;
        }
        $srt_file .= '.srt';
        if (file_exists($srt_file) && filesize($srt_file) > 0) {
            log_msg('字幕文件已存在,无需导出:' . $srt_file);
            continue;
        }
        createDirByFile($srt_file);
        $cmd = escapeshellarg($ffmpeg_bin)
            . ' -y -i ' . escapeshellarg($mkv_file)
            . ' -map 0:' . $index . ' '
            . escapeshellarg($srt_file);
        run_cmd($cmd, true);
        if (!file_exists($srt_file) || filesize($srt_file) === 0) {
            log_msg('提取字幕失败:' . $srt_file);
        } else {
            log_msg('已导出字幕:' . $srt_file);
        }
    }
}

function createDirByFile(string $file): bool
{
    if (file_exists($file)) {
        return true;
    }
    $dirname = pathinfo($file, PATHINFO_DIRNAME);
    return createDir($dirname);
}

function createDir(string $dir): bool
{
    if (is_dir($dir)) {
        return true;
    }
    return @mkdir($dir, 0777, true) || is_dir($dir);
}

/**
 * 视频转换为mp4并添加字幕
 */
function mkv_to_mp4($mkv_file, $srt_file, $mp4_file)
{
    global $ffmpeg_bin;
    if (!file_exists($ffmpeg_bin)) {
        log_msg('ffmpeg 不存在:' . $ffmpeg_bin);
        return;
    }

    $srt_exists = !empty($srt_file) && file_exists($srt_file) && filesize($srt_file) > 0;
    if ($srt_exists) {
        $srt_real = realpath($srt_file);
        $srt_file = $srt_real !== false ? $srt_real : $srt_file;
    }

    $mkv_file = str_replace('\\', '/', $mkv_file);
    $mp4_file = str_replace('\\', '/', $mp4_file);

    log_msg('开始转码视频:' . $mkv_file);
    if ($srt_exists) {
        log_msg('使用字幕文件:' . $srt_file);
    }

    if (file_exists($mp4_file) && filesize($mp4_file) > 0) {
        log_msg('翻译视频文件已存在,无需翻译:' . $mp4_file);
        return;
    }

    $vf = "scale=w='if(gt(iw,1920),1920,iw)':h='if(gt(ih,1080),1080,ih)':flags=lanczos,format=yuv420p";
    $compat_args = '-c:a aac -b:a 192k -ar 48000 -movflags +faststart -map_chapters -1';

    createDirByFile($mp4_file);

    if ($srt_exists) {
        $srt_ff = str_replace('\\', '/', $srt_file);
        $srt_escaped = str_replace(
            ['\\', "'", ':', ' ', ',', '[', ']'],
            ['\\\\', "\\'", '\\:', '\\ ', '\\,', '\\[', '\\]'],
            $srt_ff
        );
        $vf .= ",subtitles='{$srt_escaped}':force_style='FontName=KaiTi,FontSize=20'";
    }

    $cmd = escapeshellarg($ffmpeg_bin)
        . ' -y -loglevel info -stats -i ' . escapeshellarg($mkv_file)
        . ' -c:v libx264 -vf "' . $vf . '" '
        . $compat_args . ' '
        . escapeshellarg($mp4_file);

    run_cmd($cmd, true);
    if (!file_exists($mp4_file) || filesize($mp4_file) === 0) {
        log_msg('视频转换失败:' . $mp4_file);
    } else {
        log_msg('视频转换成功:' . $mp4_file);
    }
}

/**
 * 逐行翻译(每行一次请求,带前后上下文)
 */
function translate_texts_line_by_line(array $texts): array
{
    global $translate_context_size;
    if (empty($texts)) {
        return [];
    }
    $contextSize = max(0, (int)$translate_context_size);
    $total = count($texts);
    $result = [];

    for ($i = 0; $i < $total; $i++) {
        $before = $contextSize > 0
            ? array_slice($texts, max(0, $i - $contextSize), $i - max(0, $i - $contextSize))
            : [];
        $after = $contextSize > 0
            ? array_slice($texts, $i + 1, $contextSize)
            : [];

        log_msg('翻译 ' . ($i + 1) . ' / ' . $total);
        $result[] = fy_with_context($texts[$i], $before, $after);
    }
    return $result;
}

/**
 * 逐行翻译(带上下文)
 */
function fy_with_context(string $line, array $contextBefore, array $contextAfter): string
{
    $clean = clean_subtitle_text($line);
    if ($clean === '') {
        return '';
    }
    $contextBefore = array_values(array_filter(array_map('clean_subtitle_text', $contextBefore), function ($l) {
        return $l !== '';
    }));
    $contextAfter = array_values(array_filter(array_map('clean_subtitle_text', $contextAfter), function ($l) {
        return $l !== '';
    }));

    log_msg('原文:'.$clean);
    $prompt = build_translate_prompt([$clean], $contextBefore, $contextAfter);
    $result = ai_fy($prompt);
    log_msg('译文:'.$result);
    if ($result !== '') {
        return trim($result);
    }
    log_msg('翻译失败,使用原文:' . $clean);
    return $clean;
}

/**
 * 构建翻译 prompt
 */
function build_translate_prompt(array $lines, array $contextBefore, array $contextAfter): string
{
    $hasContext = !empty($contextBefore) || !empty($contextAfter);
    if ($hasContext) {
        $prompt = "【背景信息】\n";
        if (!empty($contextBefore)) {
            $prompt .= "前情字幕:\n" . implode("\n", $contextBefore) . "\n";
        }
        if (!empty($contextAfter)) {
            $prompt .= "后续字幕:\n" . implode("\n", $contextAfter) . "\n";
        }
        $prompt .= "\n请结合背景信息将以下文本翻译为中文。注意只需要输出翻译后的结果,不要额外解释,每行对应一行翻译,保持行数一致。\n";
    } else {
        $prompt = "将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释,每行对应一行翻译,保持行数一致:\n";
    }
    $prompt .= "【待翻译文本】\n" . implode("\n", $lines);
    return $prompt;
}

/**
 * 清洗字幕文本
 */
function clean_subtitle_text(string $line): string
{
    $line = strip_tags($line);
    $line = str_replace(['♪', '#', '*'], '', $line);
    if (trim($line) === '-') {
        return '';
    }
    $line = preg_replace('/^-\s*/u', '', $line);
    $line = trim($line);
    if (preg_match('/^\[.*\]$/u', $line)) {
        return '';
    }
    return $line;
}

function char_display_width(string $char): int
{
    if (!mb_check_encoding($char, 'UTF-8')) {
        return 1;
    }
    return preg_match('/[\x{4e00}-\x{9fff}\x{3000}-\x{303f}\x{ff00}-\x{ffef}]/u', $char) ? 2 : 1;
}

function line_display_width(string $line): int
{
    if (!mb_check_encoding($line, 'UTF-8')) {
        $line = mb_convert_encoding($line, 'UTF-8', 'UTF-8');
    }
    $width = 0;
    $len = mb_strlen($line, 'UTF-8');
    if ($len === false) {
        return 0;
    }
    for ($i = 0; $i < $len; $i++) {
        $width += char_display_width(mb_substr($line, $i, 1, 'UTF-8'));
    }
    return $width;
}

/**
 * 字幕超长自动换行
 */
function wrap_subtitle_line(string $line, int $maxWidth = 50): string
{
    $line = trim($line);
    if ($line === '' || line_display_width($line) <= $maxWidth) {
        return $line;
    }

    $result = [];
    $current = '';
    $currentWidth = 0;
    $lastBreak = -1;

    $len = mb_strlen($line, 'UTF-8');
    if ($len === false) {
        return $line;
    }

    for ($i = 0; $i < $len; $i++) {
        $char = mb_substr($line, $i, 1, 'UTF-8');
        $cw = char_display_width($char);

        if ($currentWidth + $cw > $maxWidth && $current !== '') {
            if ($lastBreak !== -1) {
                $keep = mb_substr($current, 0, $lastBreak + 1, 'UTF-8');
                $rest = mb_substr($current, $lastBreak + 1, null, 'UTF-8');
                $result[] = rtrim($keep);
                $current = ltrim($rest) . $char;
            } else {
                $result[] = $current;
                $current = $char;
            }
            $currentWidth = line_display_width($current);
            $lastBreak = -1;
        } else {
            $current .= $char;
            $currentWidth += $cw;
        }

        if (preg_match('/[\s,。!?、;:,.!?;:\-]/u', $char)) {
            $lastBreak = mb_strlen($current, 'UTF-8') - 1;
        }
    }
    if ($current !== '') {
        $result[] = $current;
    }
    return implode("\n", $result);
}

/**
 * 对 SRT 文件中的字幕内容行做超长自动换行(原地覆写)
 */
function wrap_srt_file(string $srt_file): void
{
    if (!file_exists($srt_file)) {
        return;
    }
    $content = file_get_contents($srt_file);
    if ($content === false) {
        return;
    }
    if (str_starts_with($content, "\xEF\xBB\xBF")) {
        $content = substr($content, 3);
    }
    $content = str_replace(["\r\n", "\r"], "\n", $content);
    $lines = explode("\n", $content);
    $out = [];
    $expectIndex = true;
    foreach ($lines as $line) {
        $trimmed = trim($line);
        if ($trimmed === '') {
            $out[] = '';
            $expectIndex = true;
            continue;
        }
        if ($expectIndex && preg_match('/^\d+$/', $trimmed)) {
            $out[] = $trimmed;
            $expectIndex = false;
        } else if (preg_match('/^\d{2}:\d{2}:\d{2},\d{3}/', $trimmed)) {
            $out[] = $trimmed;
            $expectIndex = false;
        } else {
            $expectIndex = false;
            $wrapped = wrap_subtitle_line($trimmed);
            foreach (explode("\n", $wrapped) as $wl) {
                $out[] = $wl;
            }
        }
    }
    file_put_contents($srt_file, implode("\n", $out));
}

/**
 * AI 翻译请求(无重试,失败返回空字符串,由调用方用原文兜底)
 */
function ai_fy(string $prompt): string
{
    global $ai_api, $ai_model;

    $payload = [
        "model"      => $ai_model,
        "prompt"     => $prompt,
        "stream"     => false,
        "think"      => false,
        "keep_alive" => "1m",
        "options"    => [
            "temperature"    => 0.7,
            "top_p"          => 0.6,
            "top_k"          => 20,
            "repeat_penalty" => 1.05,
            "num_predict"    => 1024,
        ],
    ];
    $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($body === false) {
        log_msg('JSON 编码失败');
        return '';
    }

    $ch = curl_init($ai_api);
    if ($ch === false) {
        log_msg('curl_init 失败');
        return '';
    }
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_ENCODING       => '',
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $errno    = curl_errno($ch);
    $errmsg   = curl_error($ch);
    unset($ch); // PHP 8.0+ 推荐,替代 curl_close

    if ($response === false || $errno !== 0) {
        log_msg("cURL 错误:[{$errno}] {$errmsg}");
        return '';
    }
    if ($httpCode !== 200) {
        log_msg("HTTP 错误:{$httpCode}");
        return '';
    }
    $data = json_decode($response, true);
    if (!is_array($data) || !isset($data['response'])) {
        log_msg('响应中没有 response 字段');
        return '';
    }
    return trim((string)$data['response']);
}