前言
视频预处理这类任务,本质上是一长串单向的数据变换:读元信息 → 算目标分辨率 → 加水印 → 拼编码参数 → 交给 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 callable,PHP 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_types=1); // 最低 PHP 8.5 —— 管道操作符 |> 是 PHP 8.5 新增的语法 /** * 第一步:把 ffprobe 的原始输出整理成平面结构。 */ final class Probe { /** @param array<string, 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( 'drawtext=text=%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)); // 设了 RUN=1 才真的执行;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 scale=1280:720,drawtext=text=demo.local:x=W-w-24:24 -c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p -movflags +faststart out.mp4设上RUN=1并且机器里有 ffmpeg,就会真的转码出out.mp4:RUN=1 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 的顺序与书写顺序一致,任何一步都能单独测试 |
视频预处理是一串典型的单向变换,管道操作符正好对上这个形状:把「参数套参数的嵌套」变成「从左到右的加工流程」,同时逼着每一步做成可独立测试的纯函数。要记住它只是语法糖——该用中间变量切断的时候切断,该加括号的时候加括号,可读性才是它唯一的卖点。