前言
用overlay滤镜加水印,命令不报错、输出也能播,但打开一看水印偏了:跑到画面外只露一角,或本该贴右下角却悬在中间,或被拉宽变形。
这类偏移不是 FFmpeg 的 bug,几乎都来自四个原因:
- 尺寸变量写混。
overlay里大写W/H指主视频,小写w/h指叠加图。x=W-w-10是"主视频宽 - 水印宽 - 10",正确;x=w-w-10恒等于-10,水印跑到左边界外。 - SAR(Sample Aspect Ratio,采样宽高比)不一致。水印图和视频 SAR 不同时,播放器为对齐显示比例会整体缩放,看起来像位置变了。
- 旋转元数据没处理。手机竖屏视频带
rotate=90,你按看到的画面量坐标,FFmpeg 按存储像素去贴,差了一次旋转。 - 坐标算的是缩放后尺寸,实际用的是缩放前尺寸。
本文按"overlay 坐标系 → SAR 与旋转 → 完整 PHP 封装"的顺序讲,命令和代码都可直接跑。示例基于 PHP 8.1+ 与 FFmpeg 4.0+(overlay很早就有,但参数表达式 4.x 后才稳定)。
一、overlay 的坐标系:大写和小写的区别
最核心也最容易记错的一点:
| 变量 | 指代 | 举例(主视频 1920x1080,水印 200x80) |
|---|---|---|
W | 主视频宽度 | 1920 |
H | 主视频高度 | 1080 |
w | 叠加图宽度 | 200 |
h | 叠加图高度 | 80 |
x/y | 叠加图左上角在主视频里的坐标 | 位置参数 |
四角写法:
overlay=x=10:y=10 # 左上 overlay=x=W-w-10:y=10 # 右上 overlay=x=10:y=H-h-10 # 左下 overlay=x=W-w-10:y=H-h-10 # 右下(最常用)心算校验:把 W/H/w/h 代进具体数字,看结果是否落在 [0, W-w] 与 [0, H-h] 内。上例x=1920-200-10=1710,而W-w=1720,在范围内,正确。
overlay的几个有用选项:
| 选项 | 默认 | 作用 |
|---|---|---|
x/y | 0 | 位置表达式,支持t(时间,秒)做动态移动 |
format | yuv420 | 覆盖层像素格式,颜色发灰/半透明发黑先怀疑它 |
eof_action | repeat | 水印图先播完时行为,可选repeat/endall/pass |
shortest | 0 | 设为1时任一输入结束就结束 |
alpha | straight | alpha 通道处理,premultiplied用于预乘 alpha 的素材 |
enable | 无 | 时间表达式,控制水印出现的时段 |
shortest=1与eof_action的组合是"偏移"的另一个隐蔽来源:水印是单帧 PNG 时会被repeat重复到尾,没问题;但水印是短视频且没设shortest=1时,输出时长会被水印视频拉长或截短,你会觉得"整段视频时间轴歪了"。
二、"偏移"的三大真凶
2.1 SAR 不一致:看起来位置偏了,其实是比例偏了
每个视频流都带 SAR 字段,显示宽高比DAR = 存储宽高比 × SAR:
- 视频
1920x1080、SAR=1:1→DAR=16:9,正常。 - 某些 DVD/老素材转来的视频
720x480、SAR=32:27→DAR≈16:9。存储是 720x480,显示成 16:9。
若水印SAR=1:1,把它overlay到SAR=32:27的视频上,FFmpeg 需统一比例。不同版本策略不同,结果是水印被横向拉长、或整幅画面被缩放,你量好的坐标全部失效。
统一解法是在 filter 链最后强制setsar=1,缩放时显式指定尺寸。
ffmpeg -i input.mp4 -i logo.png \ -filter_complex "\ [0:v]scale=1920:1080,setsar=1[base]; \ [1:v]scale=200:-1,setsar=1[wm]; \ [base][wm]overlay=x=W-w-10:y=H-h-10[out]" \ -map "[out]" -map "0:a?" \ -c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p -c:a copy \ output.mp4要点:
scale=200:-1:宽定 200,高按比例自动算。-1表示"自动算,但必须整除",比它更严格的是-2;算出奇数高度会报错,推荐一律用-2。setsar=1加在每一路缩放之后,所有支路 SAR 归一化,overlay时无比例歧义。- 想保留原始分辨率就不写
scale,只写setsar=1:[0:v]setsar=1[base]; [1:v]setsar=1[wm]; [base][wm]overlay=...。
2.2 旋转元数据:坐标系差了一次旋转
手机竖屏视频通常存成横向的1920x1080,再加rotate=90或rotate=-90元数据。播放器看到元数据会自动旋转显示,所以你看到的是1080x1920竖屏画面。
ffprobe读出的width/height是存储尺寸,不是显示尺寸。于是:
- 你按竖屏画面量出"水印在右下角,坐标约 (900, 1700)";
- FFmpeg 拿到的画面是
1920x1080,y=1700直接越界,要么把水印丢到画面外,要么报错。
两种正确做法:
# 做法 A:把旋转固化掉再处理(推荐,后续坐标都按看到的方向量) ffmpeg -i input.mp4 -c copy -metadata:s:v:0 rotate=0 -c:v libx264 -crf 20 \ -pix_fmt yuv420p normalized.mp4 # 做法 B:在滤镜链里自己 transpose,并告诉 ffmpeg 别自动旋转 ffmpeg -noautorotate -i input.mp4 -i logo.png \ -filter_complex "[0:v]transpose=1[base];[1:v]scale=200:-2[wm];[base][wm]overlay=W-w-10:H-h-10[out]" \ -map "[out]" -map "0:a?" -c:v libx264 -crf 20 -pix_fmt yuv420p -c:a copy output.mp4transpose参数:1顺时针 90 度,2逆时针 90 度,0再翻转一次。对rotate=90用transpose=1,对rotate=-90/rotate=270用transpose=2——错了视频就是倒的,比位置偏移更显眼,但一眼能看出来。
2.3 位置表达式写错
overlay=x=w-w-10:y=h-h-10 # ❌ 恒等于 -10,水印跑到左边界外 overlay=x=W-w:y=H-h # ❌ 少了边距,很多播放器会裁掉最外圈像素 overlay=x=W-w-10:y=H-h-10 # ✅ overlay=x=(W-w)/2:y=(H-h)/2 # ✅ 居中还有个更隐蔽的错误:表达式里的引号被 shell 吃掉了。在 PHP 里拼命令时若没正确转义,(W-w)/2的括号会被 bash 当成子 shell 语法,命令直接报错或被替换成空串。
三、PHP 封装:一次把三件事都做对
下面代码完整可运行。设计原则:在 PHP 侧把分辨率、SAR、旋转全读出来,算清参数再交给 FFmpeg,而不是把表达式丢给 FFmpeg 现场算。
<?php declare(strict_types=1); // 视频加水印(不偏移版),PHP 8.1+ / FFmpeg 4.0+ final class VideoWatermark { public function __construct( private readonly string $ffmpeg = 'ffmpeg', private readonly string $ffprobe = 'ffprobe', ) {} /** @return array{width:int, height:int, sar:string, rotation:int} */ public function probe(string $file): array { $out = $this->exec([ $this->ffprobe, '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,sample_aspect_ratio:stream_side_data=rotation', '-of', 'json', $file, ]); $stream = json_decode($out, true, 512, JSON_THROW_ON_ERROR)['streams'][0] ?? []; $rotation = 0; foreach ($stream['side_data_list'] ?? [] as $side) { if (isset($side['rotation'])) { $rotation = (int) $side['rotation']; } } return [ 'width' => (int) ($stream['width'] ?? 0), 'height' => (int) ($stream['height'] ?? 0), 'sar' => (string) ($stream['sample_aspect_ratio'] ?? '1:1'), 'rotation' => $rotation, ]; } /** * @param string $position top-left/top-center/top-right/middle-left/center/ * middle-right/bottom-left/bottom-center/bottom-right * @param int $scaleWidth 水印缩放后宽度;0 表示不缩放 */ public function apply( string $input, string $logo, string $output, string $position = 'bottom-right', int $margin = 10, int $scaleWidth = 0, int $opacity = 100, int $crf = 20, ): void { $vmeta = $this->probe($input); $lmeta = $this->probe($logo); // 1) 旋转 90/270 度时实际画面是 w/h 互换的 $rotated = in_array(abs($vmeta['rotation']), [90, 270], true); $baseW = $rotated ? $vmeta['height'] : $vmeta['width']; $baseH = $rotated ? $vmeta['width'] : $vmeta['height']; // 2) 水印尺寸 $wmW = $scaleWidth > 0 ? $scaleWidth : $lmeta['width']; if ($wmW >= $baseW) { throw new InvalidArgumentException( "水印宽度 {$wmW} 不小于视频宽度 {$baseW},请调小 scaleWidth" ); } // 高度由 FFmpeg 按比例算,这里只做保守估算用于边界校验 $ratio = $lmeta['width'] > 0 ? $lmeta['height'] / $lmeta['width'] : 0.0; $wmH = (int) ceil($wmW * $ratio); // 3) 坐标在 PHP 里算好,不留表达式给 FFmpeg [$x, $y] = $this->positionToXY($position, $baseW, $baseH, $wmW, $wmH, $margin); if ($x < 0 || $y < 0 || $x + $wmW > $baseW || $y + $wmH > $baseH) { throw new InvalidArgumentException( "坐标越界: 水印 {$wmW}x{$wmH} 位置 ({$x},{$y}) 画布 {$baseW}x{$baseH}" ); } // 4) filter_complex:主视频按需旋转 + 强制 SAR=1;水印缩放/透明处理 $baseFilters = []; if ($rotated) { $baseFilters[] = $vmeta['rotation'] === 90 ? 'transpose=1' : 'transpose=2'; } $baseFilters[] = 'setsar=1'; $logoFilters = []; if ($scaleWidth > 0) { $logoFilters[] = "scale={$scaleWidth}:-2"; // -2 保证偶数高度 } $logoFilters[] = 'setsar=1'; if ($opacity < 100) { $alpha = round($opacity / 100, 2); $logoFilters[] = "format=rgba,colorchannelmixer=aa={$alpha}"; } $filter = '[0:v]' . implode(',', $baseFilters) . '[base];' . '[1:v]' . implode(',', $logoFilters) . '[wm];' . sprintf('[base][wm]overlay=x=%d:y=%d:format=auto:shortest=1[out]', $x, $y); $this->exec([ $this->ffmpeg, '-y', '-i', $input, '-i', $logo, '-filter_complex', $filter, '-map', '[out]', '-map', '0:a?', // 有音频就带上,没有也不报错 '-c:v', 'libx264', '-crf', (string) $crf, '-preset', 'medium', '-pix_fmt', 'yuv420p', '-c:a', 'copy', '-movflags', '+faststart', $output, ]); } /** @return array{0:int, 1:int} */ private function positionToXY( string $position, int $baseW, int $baseH, int $wmW, int $wmH, int $margin, ): array { $x = $margin; // 水平默认贴左 if ($position === 'center' || str_ends_with($position, '-center')) { $x = (int) round(($baseW - $wmW) / 2); } elseif (str_ends_with($position, '-right')) { $x = $baseW - $wmW - $margin; } $y = $margin; // 垂直默认贴上 if ($position === 'center' || str_starts_with($position, 'middle-')) { $y = (int) round(($baseH - $wmH) / 2); } elseif (str_starts_with($position, 'bottom-')) { $y = $baseH - $wmH - $margin; } return [$x, $y]; } /** @param list<string> $args */ private function exec(array $args): string { $cmd = implode(' ', array_map( static fn (string $a): string => escapeshellarg($a), $args )); $process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) { throw new RuntimeException('无法启动 ffmpeg/ffprobe'); } $stdout = stream_get_contents($pipes[1]) ?: ''; $stderr = stream_get_contents($pipes[2]) ?: ''; fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($process); if ($code !== 0) { $tail = implode("\n", array_slice(explode("\n", trim($stderr)), -20)); throw new RuntimeException("ffmpeg 失败(exit {$code}):\n{$tail}"); } return $stdout; } } // ------------------------------------------------------------------ $wm = new VideoWatermark(); $wm->apply( input: __DIR__ . '/input.mp4', logo: __DIR__ . '/logo.png', output: __DIR__ . '/output.mp4', position: 'bottom-right', margin: 20, scaleWidth: 160, opacity: 80, ); echo "水印添加完成\n";这段代码把偏移的三个来源都提前掐掉:
- 旋转:PHP 里判断画面实际方向,决定
transpose=1还是transpose=2,并用交换后的基准尺寸算坐标。 - SAR:每路都加
setsar=1,overlay两侧比例归一。 - 坐标系:坐标在 PHP 里用具体数字算好(
$baseW - $wmW - $margin),传进overlay的是x=1710这样的常量而非W-w-10表达式,从根本上不存在大小写写混的可能。
注意overlay=...:format=auto。默认的format=yuv420会把带 alpha 的水印强行转成不带 alpha 的格式,透明背景变成黑色方块。水印 logo 一定要用带透明通道的 PNG,并把format设为auto。
常见坑点
1.w/h和W/H写反
❌overlay=x=w-w-10:y=h-h-10恒等于(-10,-10),水印跑到左上角外,输出里什么都看不到。 ✅ 记住大写是主视频、小写是叠加图:x=W-w-10:y=H-h-10。
2. 水印 PNG 没有透明通道,变成黑块
❌ 用 JPG 当水印,或截图工具存的"看起来背景是白的"PNG —— 白色背景会实打实盖住画面。 ✅ 用绘图工具导出带 alpha 通道的 PNG,并在overlay里加format=auto。判断方法:ffprobe看pix_fmt,rgba/pal8带 alpha,rgb24不带。
3. 忘记setsar=1
❌ 视频SAR=32:27、水印SAR=1:1直接overlay—— 整幅画面被横向拉伸,水印跟着变宽,看起来"位置偏了",实际是被拉伸了。 ✅ 每个缩放分支后都加setsar=1。
4. 竖屏视频坐标全错
❌ 直接按看到的竖屏画面量坐标喂给 FFmpeg —— 存储坐标系是旋转前的,y值会越界。 ✅ 先ffprobe读side_data_list的rotation,在 PHP 里交换基准宽高再算坐标,并在滤镜链里transpose。
5. 同一条命令里混用-vf和-filter_complex
❌-vf "scale=1280:-2"与-filter_complex "...[out]"同时出现 —— 两个滤镜图打架,行为不确定,常见结果是-vf被忽略(你以为的缩放根本没发生,坐标自然全错)。 ✅ 只要涉及两个输入就统一只用-filter_complex,把缩放写进链里:[0:v]scale=1280:-2,setsar=1[base]。
6.-c:v copy与滤镜同用 /-ss放错位置
❌-vf overlay=... -c:v copy报Filtering and streamcopy cannot be used together,因为滤镜必须解码再编码。加滤镜就必须重新编码(libx264等)。 ✅-ss放在-i之后是"解码到该秒再输出",很慢但精确;追求速度应放-i之前(关键帧定位,可能不精确到帧)。用滤镜时推荐用输出侧的-ss/-to做时间裁剪,语义更清晰。
总结
| 偏移症状 | 真正原因 | 修复 |
|---|---|---|
| 水印跑到画面外 / 完全看不见 | W/w大小写写混 | 大写主视频、小写叠加图 |
| 水印整体被拉伸变宽 | SAR 不一致 | 每个分支后加setsar=1 |
| 坐标怎么调都对不上 | 旋转元数据没处理 | 读rotation,交换宽高 +transpose |
| 水印背景变黑色方块 | 水印无 alpha 或format=yuv420 | 用带 alpha 的 PNG +format=auto |
| 位置"看起来"偏了几像素 | 算的是缩放前尺寸 | 把计算挪到 PHP 里,用缩放后的数值 |
| 视频时长异常 | 水印先结束且未设shortest | 加shortest=1 |
避免水印偏移的根本办法只有一个:不要在 FFmpeg 表达式里做算术,在 PHP 里把最终坐标算成确定的整数再传进去。表达式写在命令行里既无法断点调试,也无法提交前校验;写在 PHP 里,越界、比例、旋转三类问题都能在调用 FFmpeg 之前抛异常拦下来,报错明确,排查从半小时缩短到一眼。