
前言症状往往非常反直觉同样的命令在命令行窗口里手敲一遍完全正常写进 PHP 里就报No such file or directory文件确实存在权限也没问题把文件名换成test.mp4立刻就好了。更有迷惑性的是报错信息里打印出来的路径看起来是对的——因为 PHP 输出的乱码路径和实际传入的路径长得一样。原因并不在 ffmpeg而在参数从 PHP 传到 ffmpeg 的过程中字符集换了三次PHP 脚本里的字符串是 UTF-8交给 shell 时要变成 shell 能理解的编码shell 再传给 ffmpeg 进程而 ffmpeg 最终在 Windows 上要通过文件系统接口去打开文件。这条链上任何一环的编码假设不一致中文字符就会变成一堆问号或者几个毫不相干的汉字于是文件不存在。本文把这条链路拆开给出四种可行方案从最稳到最将就并封装成一个可以直接用在你项目里的运行器把中文路径的输入先复制到纯 ASCII 临时路径处理处理完再搬回去。文中代码需要 PHP 7.4 及以上用到了proc_open的数组命令形式并会标注 Windows 与 Linux 的差异。一、中文路径为什么会变成不存在的文件一次调用的完整链路是这样的PHP 字符串(UTF-8) → exec/proc_open → shell(cmd.exe) → ffmpeg.exe → 文件系统四个环节各自有编码假设PHP 字符串源码文件是 UTF-8字面量D:/视频/a.mp4在内存里就是 UTF-8 字节。shellWindows 的cmd.exe用活动代码页简体中文环境下通常是 936即 GBK解释命令行。UTF-8 的汉字被按 GBK 逐字节读就成了乱码文件名。ffmpeg进程启动时拿到的是已经被转换过的命令行它只能按拿到的结果去找文件。文件系统Windows 内部用 UTF-16 存文件名ANSI 接口要用代码页做一次转换转换错位就找不到。Linux 上通常是另一回事现代发行版默认 locale 是en_US.UTF-8或C.UTF-8shell 和文件系统基本是字节直通UTF-8 文件名能正常打开所以同一个项目在 Linux 上不出问题、在 Windows 上必炸。这也是为什么这类 bug 总在本地开发正常、Windows 服务器报错时冒出来。二、四种方案对比方案Windows 中文路径注入风险适用场景exec()拼字符串 escapeshellarg()有坑可能被替换成空格中快速脚本路径无特殊字符用mb_convert_encoding转成本地代码页可用中必须走 shell 且路径简单proc_open()传数组绕开 shell 解析低参数多、需要精确控制复制到 ASCII 临时路径处理稳定与编码无关低生产环境首选先看escapeshellarg()在 Windows 上的行为差异这是最容易踩的一个在 Linux 上它用单引号包裹并转义单引号在 Windows 上它用双引号包裹并且会把%、!、这三个字符替换成空格。于是D:/视频/100%.mp4会变成D:/视频/100 .mp4路径当然不存在。注意%在 Windows 里本是环境变量展开符!是延迟展开符PHP 这么做是为了防注入但它同时毁掉了文件名。三、实战一个与编码解耦的运行器思路很简单不要试图让中文路径穿过 shell。把源文件复制到sys_get_temp_dir()下一个随机 ASCII 名字的临时文件ffmpeg 全程只处理 ASCII 路径处理完把产物rename()回中文目标路径。代价是一次磁盘复制同盘内可以用硬链接或直接读取跨盘时老老实实复制换来的是在所有平台、所有代码页下都稳定。?php // ffmpeg_zh.php —— 需要 PHP 7.4 及以上proc_open 的数组命令形式 declare(strict_types1); /** * 以数组形式启动 ffmpeg参数不经过 shell天然免疫引号与注入问题。 * * return array{code:int, out:string, err:string} */ function runFfmpeg(array $args, string $binary ffmpeg): array { $cmd array_merge([$binary, -hide_banner, -nostdin, -y], $args); $spec [ 0 [pipe, r], // 子进程读父进程写喂进度数据 1 [pipe, w], // 子进程写父进程读 2 [pipe, w], ]; $proc proc_open($cmd, $spec, $pipes); if (!is_resource($proc)) { throw new RuntimeException(无法启动 ffmpeg请确认已安装并在 PATH 中); } fclose($pipes[0]); // 注意这里假设 stderr 输出量不大配合 -loglevel error 使用。 // 若是长时间任务且要解析进度应改用非阻塞读 stream_select避免管道写满导致死锁。 $out stream_get_contents($pipes[1]); $err stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); return [code proc_close($proc), out $out, err $err]; } /** * 中文路径友好的转码内部走 ASCII 临时路径产物搬回目标路径。 */ function transcode(string $src, string $dst, array $extraArgs []): void { if (!is_file($src)) { throw new RuntimeException(源文件不存在: {$src}); } $ext pathinfo($src, PATHINFO_EXTENSION) ?: bin; $rand bin2hex(random_bytes(8)); $dir sys_get_temp_dir(); $tmpIn $dir . DIRECTORY_SEPARATOR . ff_in_{$rand}.{$ext}; $tmpOut $dir . DIRECTORY_SEPARATOR . ff_out_{$rand}. . (pathinfo($dst, PATHINFO_EXTENSION) ?: mp4); if (!copy($src, $tmpIn)) { throw new RuntimeException(复制到临时路径失败: {$src}); } try { $args array_merge( [-i, $tmpIn, -c:v, libx264, -crf, 23, -preset, medium, -c:a, aac], $extraArgs, [$tmpOut], ); $r runFfmpeg($args, PHP_OS_FAMILY Windows ? ffmpeg.exe : ffmpeg); if ($r[code] ! 0) { throw new RuntimeException(ffmpeg 退出码 {$r[code]}: {$r[err]}); } if (!rename($tmpOut, $dst)) { throw new RuntimeException(把结果移回目标路径失败: {$dst}); } } finally { // 成功时 $tmpOut 已被 rename 走这里的 unlink 是无害的兜底 if (is_file($tmpIn)) { unlink($tmpIn); } if (is_file($tmpOut)) { unlink($tmpOut); } } } // ---- 调用示例 ---- try { transcode( D:/素材/中文 视频.mp4, D:/素材/输出结果.mp4, [-metadata, title中文标题], ); echo 完成\n; } catch (Throwable $e) { echo 失败: , $e-getMessage(), \n; }如果因为环境限制必须走 shell例如老代码里到处是exec或者要串接 shell 管道那就退回到转成本地代码页的方案。要点是只转换路径本身不要转换命令关键字并且别再叠加escapeshellarg()否则%又会被打成空格。?php // 需要 PHP 8.0 及以上 declare(strict_types1); /** 把 UTF-8 路径转成 Windows 本地代码页供 cmd.exe 使用 */ function toLocalPath(string $utf8Path): string { if (PHP_OS_FAMILY ! Windows) { return $utf8Path; // Linux/macOS 一般直接透传 UTF-8 字节 } // 简体中文环境代码页为 936GBK。用 sapi_windows_cp_get() 可以拿到当前活动代码页 // 该函数仅 Windows 且 PHP 7.1 起可用所以先做存在性判断。 $cp function_exists(sapi_windows_cp_get) ? (sapi_windows_cp_get() ?: 936) : 936; $converted mb_convert_encoding($utf8Path, CP . $cp, UTF-8); return $converted ! false ? $converted : $utf8Path; } $path D:/素材/中文视频.mp4; $cmd ffmpeg -loglevel error -i . toLocalPath($path) . -c copy D:/out.mp4; exec($cmd, $output, $code); var_dump($code, $output);顺带说一句 Windows 上仍然有效的两个写法小技巧路径里统一用正斜杠D:/素材/a.mp4比反斜杠省心因为反斜杠在 PHP 字符串里还要转义一次含空格的路径必须加引号这一点跟编码无关。四、Linux 与容器环境的差异在 Linux 上遇到中文路径失败原因通常不是代码页而是这几种locale 未生成容器镜像里只有C/POSIXlocale某些程序会按 ASCII 处理文件名字节。安装并生成en_US.UTF-8或在启动进程时显式设置LANGC.UTF-8。sys_get_temp_dir()不可写临时路径方案在容器里依赖/tmp只读根文件系统或挂了noexec卷时会失败注意给临时目录留可写卷。跨文件系统rename()如果/tmp是独立挂载点、目标目录在另一挂载点rename()会返回false。同盘内才保证是原子的移动跨盘要退化成复制 删除。文件名含?、*、换行这些字符在 Linux 上是合法的但只要经过任何一次 shell 解释就会出问题——这又回到了别让文件名穿过 shell的结论。常见坑点❌ 用escapeshellarg(D:/视频/100%.mp4)处理 Windows 路径结果%变成空格文件名对不上✅ Windows 上自己按本地代码页转换并手动加双引号或者直接用proc_open()的数组形式绕开 shell❌ 只把命令里的路径转成 GBK却把-i、-c:v这些选项也一起转或者反过来只转了一半✅ 编码转换只作用于路径/文件名命令选项保持 ASCII 原样❌ 看到No such file or directory就去改权限、改 ffmpeg 安装路径✅ 先用file_exists()在 PHP 侧确认文件真的存在再打印bin2hex($path)看字节能存在却报错就是编码问题不是权限问题❌ 把中文路径写进 PHP 源码却把文件存成 GBK 编码脚本本身先乱了一层✅ 源码统一 UTF-8不带 BOM字符串常量才有确定的编码前提❌ 用临时路径方案时忘了finally里的清理跑几百次任务后/tmp撑满✅ 无论成功失败都清理输入临时文件输出临时文件在rename失败时也要清理❌ 假设rename()一定能成功忽略它在跨文件系统时返回false✅ 检查返回值必要时退化为copy()unlink()并在失败时抛异常而不是静默❌ 用exec()不接第三个参数拿退出码只看输出为空就以为成功✅ 传$code并判断$code ! 0ffmpeg 出错时错误信息走 stderr$output常常是空的❌ 长时间任务用stream_get_contents()一次性读 stdout管道写满后进程卡死✅ 用非阻塞读配合stream_select或者加-progress pipe:1 -nostats解析进度行并限制日志等级总结环节出错表现处理方式PHP 字符串编码源码非 UTF-8 时全盘乱源码统一 UTF-8 无 BOMshell 解释Windows 下中文变乱码避免走 shell或用数组形式proc_openescapeshellarg%!被替换成空格Windows 上不要用它转中文路径ffmpeg 取文件名No such file or directory改走 ASCII 临时路径最稳临时文件清理磁盘被吃满try/finally中清理跨文件系统rename()返回false退化为复制后删除结论就一句话不要让中文文件名穿过 shell。能用proc_open()的数组形式就用数组形式需要绝对稳妥就把输入复制到 ASCII 临时路径、处理完再搬回去。这两招都不依赖具体的代码页设置在 Windows 服务器、Linux 和容器里行为一致省下的调试时间远超一次文件复制的成本。