一、项目概述
在海外视频本地化、自媒体素材处理、外文教学视频汉化等场景中,视频字幕翻译、内嵌压制是高频刚需工作。传统人工翻译效率低下,在线翻译工具存在文件隐私泄露、批量处理受限、水印收费等问题。
本文介绍一套基于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平台搭建,需提前安装配置以下工具:
- PHP 8.2+:配置独立运行目录,开启curl扩展(必备,用于AI接口请求)
- FFmpeg:完整编译包,配置bin目录可执行文件路径,用于音视频处理
- OpenCC:带配置文件的完整编译包,保留t2s.json繁简转换模板
- 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']);
}