
前言视频预处理这类任务本质上是一长串单向的数据变换读元信息 → 算目标分辨率 → 加水印 → 拼编码参数 → 交给 ffmpeg。可它写出来经常是这个样子$argv Planner::toArgv(Watermark::apply(Planner::fit(Probe::normalize($raw), 1280, 720), example.com), out.mp4);这行代码能跑但没人愿意读它。括号从里往外数参数离函数名越来越远中间任何一步想插一句话、想临时打印一下中间结果都得把整行拆开重写。PHP 8.5 引入了管道操作符pipe operator|把这种「嵌套调用」改写成「从左到右的流水线」。语法很直白$x | f(...)等价于f($x)。注意管道操作符确实是PHP 8.5新增的语法这个标题里的版号没有写错不过它只是语法糖不改变任何运行时行为也不会让代码变快——它买到的是可读性和可维护性。本文用一个完整可运行的「视频预处理计划生成器」把这件事讲清楚为什么管道适合这类任务、它有哪些硬性约束、以及怎么和 ffmpeg 这种「参数是数组、必须整体拼」的外部命令配合。示例最低要求PHP 8.5。一、管道操作符的语义与三条硬约束先把语义钉死。下面两种写法完全等价// 写法 A嵌套 $result f3(f2(f1($input))); // 写法 B管道 $result $input | f1(...) | f2(...) | f3(...);几个关键点右侧必须是一个「可调用表达式」不是一个「调用」。所以写的是f1(...)一等可调用语法first-class callablePHP 8.1 起支持而不是f1()。(...)表示「把这个函数本身作为一个值取出来」。左侧的值作为唯一的实参传给右侧。右侧如果还有别的参数那些参数必须有默认值否则就会ArgumentCountError。多参数场景用闭包包一层。右侧不能是首参按引用传参的函数。像sort()、array_push()、preg_match()这类第一个参数是引用或出参的函数不能直接放到管道右边。另外管道只传第一个参数所以「带配置项」的函数需要重新设计签名要么给默认值要么改成把配置塞进被流水线传递的那个数组里。这个约束看起来别扭实际写下来反而会逼你把每一步都做成「输入一个结构输出一个结构」的纯函数——这正是流水线好维护的原因。对比项嵌套调用f3(f2(f1($x)))管道 $x \ f1(...) \ f2(...)阅读方向从内往外和书写顺序相反从左到右和书写顺序一致中间插一步要动括号层级加一行 \ 即可复制中间结果调试需要把表达式拆开重写在任意位置切断赋值给变量参数与函数名的距离参数越靠后离函数名越远永远是f(...)紧接着运行时开销完全相同完全相同纯语法糖二、把视频预处理拆成「不可变快照」的流水线要让管道好用第一件事是定义一个贯穿全流程的数据结构。这里用一个普通关联数组充当「预处理计划」每一步函数读它、补几个键、再返回一个新数组不修改入参。这样每一步都可以单独测试也可以随时切断查看。三个角色Probe::normalize()把ffprobe的 JSON 结果整理成平面结构Planner::fit()/Planner::withOutput()/Watermark::apply()逐步补全计划Planner::toArgv()把计划翻译成 ffmpeg 的 argv 数组。为什么最后输出的是argv 数组而不是一行字符串因为视频标题、水印文案里经常带中文、空格、单引号拼成字符串就不得不做 shell 转义而转义是出 bug 的重灾区。argv 数组配合proc_open()直接执行完全不经过 shell天然免疫注入和转义问题。偶数的宽高也是必须的H.264 常用的yuv420p色度采样是 2x2 下采样宽高为奇数时 ffmpeg 会报width not divisible by 2。所以fit()里做了向下取偶。三、完整可运行的示例下面这份代码保存成pipeline.php用php pipeline.php就能跑不需要安装 ffmpeg默认只打印生成的 argv。最低要求PHP 8.5。?php declare(strict_types1); // 最低 PHP 8.5 —— 管道操作符 | 是 PHP 8.5 新增的语法 /** * 第一步把 ffprobe 的原始输出整理成平面结构。 */ final class Probe { /** param arraystring, mixed $raw */ public static function normalize(array $raw): array { $streams $raw[streams] ?? []; $video []; $hasAudio false; foreach ($streams as $s) { if (($s[codec_type] ?? ) video $video []) { $video $s; } if (($s[codec_type] ?? ) audio) { $hasAudio true; } } return [ input (string)($raw[format][filename] ?? input.mp4), width (int)($video[width] ?? 0), height (int)($video[height] ?? 0), fps self::rate((string)($video[r_frame_rate] ?? 0/1)), bitrate (int)($raw[format][bit_rate] ?? 0), hasAudio $hasAudio, ]; } private static function rate(string $r): float { [$n, $d] array_pad(explode(/, $r, 2), 2, 1); $d (float)$d; return $d 0.0 ? 0.0 : round((float)$n / $d, 3); } } /** * 第二步逐步补全「预处理计划」。每一步都返回新数组不修改入参。 */ final class Planner { /** 等比缩放到不超过 maxW x maxH宽高向下取偶 */ public static function fit(array $plan, int $maxW 1280, int $maxH 720): array { $w max(1, $plan[width]); $h max(1, $plan[height]); $scale min($maxW / $w, $maxH / $h, 1.0); $plan[outWidth] max(2, (int)(floor($w * $scale / 2) * 2)); $plan[outHeight] max(2, (int)(floor($h * $scale / 2) * 2)); return $plan; } public static function withOutput(array $plan, string $output out.mp4): array { $plan[output] $output; return $plan; } /** 翻译成 ffmpeg 的 argv 数组不经过 shell无需转义 */ public static function toArgv(array $plan): array { $vf [sprintf(scale%d:%d, $plan[outWidth], $plan[outHeight])]; if (($plan[watermark] ?? null) ! null) { $vf[] sprintf( drawtexttext%s:x%s, self::escapeFilterValue($plan[watermark][text]), $plan[watermark][position] ); } $argv [ ffmpeg, -hide_banner, -nostdin, -y, -i, $plan[input], -vf, implode(,, $vf), ]; if ($plan[hasAudio] false) { $argv[] -an; } return array_merge($argv, [ -c:v, libx264, -preset, medium, -crf, 23, -pix_fmt, yuv420p, -movflags, faststart, $plan[output], ]); } /** drawtext 的 text 值里冒号和反斜杠要转义 */ private static function escapeFilterValue(string $v): string { return str_replace([\\, :, ], [\\\\, \\:, \\], $v); } } final class Watermark { /** * 位置用 ffmpeg 的表达式语法W-w-24 表示距右边缘 24 像素。 * 传空字符串表示「本次不加」因为管道只传一个参数只能靠默认值给出水印文案。 */ public static function apply(array $plan, string $text demo.local, string $position W-w-24:24): array { if ($text ) { return $plan; } $plan[watermark] [text $text, position $position]; return $plan; } } // --------------------------------------------------------------- // 流水线本体从 ffprobe 的原始输出一路走到 ffmpeg 的 argv // --------------------------------------------------------------- $raw [ format [filename raw/2026-demo.mp4, bit_rate 4820000], streams [ [codec_type video, width 3840, height 2160, r_frame_rate 30000/1001], [codec_type audio, codec_name aac], ], ]; $plan $raw | Probe::normalize(...) | Planner::fit(...) | Watermark::apply(...) | Planner::withOutput(...); $argv $plan | Planner::toArgv(...); // 打印中间结果便于排查 printf(源尺寸: %dx%d %s fps\n, $plan[width], $plan[height], $plan[fps]); printf(输出尺寸: %dx%d\n, $plan[outWidth], $plan[outHeight]); printf(命令: %s\n, implode( , $argv)); // 设了 RUN1 才真的执行argv 数组直接交给 proc_open不经过 shell if (getenv(RUN) 1) { $proc proc_open($argv, [1 STDOUT, 2 STDERR], $pipes); if (is_resource($proc)) { exit(proc_close($proc)); } fwrite(STDERR, 无法启动 ffmpeg请确认已安装\n); exit(127); }运行输出php pipeline.php源尺寸: 3840x2160 29.97 fps 输出尺寸: 1280x720 命令: ffmpeg -hide_banner -nostdin -y -i raw/2026-demo.mp4 -vf scale1280:720,drawtexttextdemo.local:xW-w-24:24 -c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p -movflags faststart out.mp4设上RUN1并且机器里有 ffmpeg就会真的转码出out.mp4RUN1 php pipeline.php。四、右侧要带额外参数时怎么办管道只传一个实参所以两种办法办法一给默认值推荐。上面Planner::fit()的$maxW 1280, $maxH 720、Watermark::apply()的$text和$position都是默认值——这正是「不加参数也能在管道里跑」的关键。需要自定义时改默认值或把配置塞进被传递的那个数组里。办法二用闭包包一层。当一步需要额外的、每次都不同的参数时$toSmall static fn (array $p): array Planner::fit($p, 640, 360); $small $raw | Probe::normalize(...) | $toSmall | Planner::toArgv(...);也可以把闭包存进变量再传给管道$fn static fn (...)...; $x | $fn;两种形式都合法。常见坑点❌ 写成$x | strlen()把管道右边当普通函数调用✅ 必须用一等可调用语法$x | strlen(...)(...)才表示「取出函数本身」❌ 把首参是引用/出参的函数放进管道比如$arr | sort(...)✅ 引用传参的函数不能作管道右值sort()又是原地排序且只返回bool正确做法是包一层返回新数组的静态方法sort($a); return $a;再用$arr | Sorter::sorted(...)❌ 把preg_match()放在管道右边以为能把匹配结果传出来✅preg_match(string $pattern, string $subject, $matches)的$subject不是第一个参数且$matches是出参管道传不进去正确做法是包一层返回array{ok: bool, matches: array}的静态方法❌ 忘记管道只传一个参数接了个f($a, $b)的双参数函数运行时抛ArgumentCountError✅ 第二个参数给默认值或用闭包捕获❌ 在管道里混用算术、字符串拼接、比较凭记忆猜优先级✅ 一律显式加括号。管道是链式顺序执行的任何「顺便算一下」的表达式都单独提出来赋值❌ 一条管道写 12 步中间报错时完全看不出是哪一步炸的✅ 每 3~4 步切一刀赋值给命名变量或在关键位置插一个「只打印不修改」的tap()辅助函数❌ 用管道替代事务性操作比如$row | $pdo-exec(...)exec()首参不是它想要的、PDOStatement还要再 prepare✅ 管道只适合「纯数据变换」有副作用、有状态、需要错误分支的步骤老老实实写多行❌ 以为用了管道性能会变好把热点循环里的函数链改成管道形式✅ 管道是编译期的语法糖生成的 opcode 与嵌套调用一致要提速应该看算法复杂度和 I/O 次数而不是改写调用形式总结要点说明引入版本管道操作符 \等价关系$x \右值形式必须是一等可调用f(...)、闭包变量不能是f()参数约束只传第一个实参其余参数必须有默认值或用闭包捕获引用约束首参按引用传参的函数不能作右值适合场景单向、无状态、每步「输入结构 → 输出结构」的数据变换不适合场景有副作用、有错误分支、需要中间出参的过程式逻辑视频场景收益元信息 → 缩放 → 水印 → 参数 → argv 的顺序与书写顺序一致任何一步都能单独测试视频预处理是一串典型的单向变换管道操作符正好对上这个形状把「参数套参数的嵌套」变成「从左到右的加工流程」同时逼着每一步做成可独立测试的纯函数。要记住它只是语法糖——该用中间变量切断的时候切断该加括号的时候加括号可读性才是它唯一的卖点。