
前言先说清楚边界ffmpeg 不是一个 PHP 扩展PHP 在这件事里的角色是拼命令、起进程、收结果。所以用 PHP 8.5 给视频加动态 logo实际是两件事的组合——用 ffmpeg 的overlay滤镜把 logo 合成到视频上并用 PHP 把滤镜表达式拼出来、把进程跑起来。PHP 8.5 在这里的价值不在于视频能力而在于写这套胶水代码时更顺手本文会用到 8.5 的管道操作符|和array_last()。典型症状是这几类命令在终端里跑得好好的搬到 PHP 里就报Error parsing filterchain或者接口一直不返回、进程挂着不动或者脚本执行成功但输出文件是 0 字节。这些几乎都和参数拼接与进程管理有关而不是滤镜本身。本文会讲清 overlay 滤镜的坐标系与时间变量、动态效果的三种实现方式、如何用proc_open()的数组形式安全地起进程并给出一个可以直接跑起来的完整类。最低版本要求 PHP 8.5同时需要系统里已经安装 ffmpegffmpeg -version能打印版本。一、动态 logo的三种形态在 ffmpeg 里做动态效果本质是让overlay滤镜的参数随时间变化位移漂移把 logo 的x或y写成一个含变量t的表达式让它来回移动淡入淡出用fade滤镜的alpha1模式做透明通道渐变它需要输入先转成rgba按时间出现/消失用overlay的enable选项取值是between(t,开始秒,结束秒)这样的表达式。三者可以叠加。下面的例子是右下角起手、做正弦漂移、开头 0.6 秒淡入。二、overlay 的坐标系与可用变量overlay的表达式里这些变量是固定的含义变量含义W/H主视频底图的宽 / 高w/h叠加层logo的宽 / 高t当前时间戳单位秒n当前帧序号所以距右下角margin像素就是xW-w-margin、yH-h-margin。想让它以period秒为周期左右摆动margin像素就写xW-w-marginmargin*sin(2*PI*t/period)。有一个必须记住的转义规则滤镜图filtergraph里用逗号分隔滤镜所以表达式内部的逗号必须被保护起来。实践中用单引号把整个表达式包住即可x...里的逗号不会被当成滤镜分隔符。同理表达式里的冒号也要小心——overlay的选项之间就是用冒号分隔的。三、用 PHP 8.5 起进程数组形式与退出码proc_open()从 PHP 7.4 起接受数组形式的命令在 Linux/macOS 上会直接开进程而不经过 shell因此不存在命令注入与转义问题注意Windows 上 PHP 会把数组拼成命令行仍需避免把用户输入拼进参数。这是比exec()escapeshellarg()更稳的选择。另一个坑在退出码上proc_get_status()一旦发现进程已经结束再调用proc_close()得到的返回码可能是 -1而不是真实退出码。因为-1在 shell 里是个合法返回值进程被信号杀死很容易被误判。正确做法是从proc_get_status()的exitcode取真实退出码proc_close()只用来回收资源。PHP 8.5 的管道操作符|在这里正好顺手把 stderr 拆成行数组后直接丢给array_last()取最后一行做错误提示。四、完整可运行示例?php // 最低版本PHP 8.5用到了管道操作符 | 与 array_last() declare(strict_types1); final class LogoOverlay { public function __construct( private string $ffmpeg ffmpeg, ) { if (!function_exists(proc_open)) { throw new RuntimeException(proc_open 被 disable_functions 禁用了); } } /** * 给视频叠加一个会漂移的 logo。 * * param float $period 横向漂移一个来回的周期秒 */ public function render( string $video, string $logo, string $out, int $logoWidth 140, int $margin 24, float $period 4.0, ): string { // 表达式用单引号包住保护内部的逗号与冒号 $graph sprintf( [1:v]formatrgba,fadetin:st0:d0.6:alpha1,scale%d:-1[logo]; . [0:v][logo]overlayxW-w-%d%d*sin(2*PI*t/%s):yH-h-%d:formatauto, $logoWidth, $margin, $margin, rtrim(rtrim(sprintf(%.3F, $period), 0), .), // 4.0 - 4 $margin ); $args [ $this-ffmpeg, -hide_banner, -nostdin, // 不要读终端防止在 FPM 下被 stdin 事件干扰 -y, // 输出文件已存在时直接覆盖避免交互式确认 -i, $video, -i, $logo, -filter_complex, $graph, -c:v, libx264, // 有滤镜就必须重编码视频流 -preset, medium, -crf, 20, -pix_fmt, yuv420p, -c:a, copy, // 音频可以直接复制 -movflags, faststart, $out, ]; $this-run($args); return $out; } /** param liststring $args */ private function run(array $args): void { $descriptors [ 0 [file, /dev/null, r], 1 [pipe, w], 2 [pipe, w], ]; $proc proc_open($args, $descriptors, $pipes); if (!is_resource($proc)) { throw new RuntimeException(无法启动 ffmpeg); } stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $stderr ; $exitCode null; while (true) { $stderr . (string) fread($pipes[2], 8192); $status proc_get_status($proc); if (!$status[running]) { $exitCode $status[exitcode]; // 真实退出码在这里 break; } usleep(50_000); // 让出 CPU避免空转 } $stderr . (string) stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); proc_close($proc); // 只回收资源不用它的返回值 if ($exitCode ! 0) { $lines preg_split(/\R/, trim($stderr)) ?: []; $tail $lines [] ? (无输出) : ($lines | array_last(...)); throw new RuntimeException(ffmpeg 失败退出码 {$exitCode}{$tail}); } } } set_time_limit(0); // 长视频转码会超时 $tool new LogoOverlay(/usr/bin/ffmpeg); echo $tool-render(input.mp4, logo.png, output.mp4), PHP_EOL;这段代码里的关键点是命令用数组传、-y与-nostdin都加上、退出码从proc_get_status()取、stderr 收集到最后一行用于报错。想加进度条可以再加-progress pipe:1 -nostats然后读pipes[1]解析out_time_ms之类的键值对。常见坑点1. 把用户输入拼进命令行❌exec(ffmpeg -i {$userFile} ...)——$userFile是a.mp4; rm -rf /时后果自负。 ✅ 用proc_open()的数组形式确实要拼字符串时每个参数都过escapeshellarg()并且对文件名做白名单校验。2. 滤镜表达式里的逗号没被保护❌-vf overlayxW-w-10,yH-h-10——逗号会被当成滤镜分隔符报Error parsing filterchain或者行为完全不对。 ✅ 表达式用单引号包住overlayxW-w-1010*sin(t):yH-h-10。3. 没加-y又碰上输出文件已存在❌ ffmpeg 会停在 File out.mp4 already exists. Overwrite? [y/N] 等输入PHP-FPM 里的表现是请求一直不返回直到max_execution_time把它杀掉。 ✅ 加-y或者在 PHP 侧先检查并改用带时间戳的临时文件名。4. 用proc_close()的返回值判断成败❌ 先proc_get_status()判断进程结束再$code proc_close($proc);然后if ($code ! 0)——可能拿到-1明明成功却当成失败。 ✅ 从proc_get_status()的exitcode取值proc_close()只回收资源。5. 既写了-filter_complex又用-c:v copy❌ 视频流没有被解码重编码滤镜无从施加ffmpeg 会直接报错或者输出不含 logo 的文件。 ✅ 用滤镜就必须重编码视频流如-c:v libx264音频可以用-c:a copy。6. 环境里没有 ffmpeg或proc_open被禁用❌ 在共享主机上直接调用报Call to undefined function proc_open()或posix_spawn失败有些环境只是静默返回 false代码却继续往下跑。 ✅ 在构造函数里自检function_exists(proc_open)并用ffmpeg -version做一次可用性探测后再接单。7. 输出路径和输入路径相同❌render(a.mp4, ..., a.mp4)——转码中途源文件被覆盖得到一个损坏的文件且没有备份。 ✅ 一律输出到临时文件成功后再用rename()原子替换。总结要点做法原因起进程proc_open()数组形式绕开 shell无注入与转义问题真实退出码proc_get_status()[exitcode]proc_close()此时可能返回 -1滤镜表达式用单引号包住保护内部的逗号与冒号交互阻塞加-y和-nostdin否则会等输入请求挂死视频重编码-c:v libx264有滤镜就不能copy输出安全先写临时文件再改名避免覆盖源文件把 ffmpeg 交给 PHP 的核心只有两个动作拼出一条每个字符都可控的命令以及可靠地拿到它的退出码和 stderr。滤镜部分交给 ffmpeg 自己的表达式语言PHP 侧只负责把表达式安全地拼对动态 logo 这类需求就不难落地了。