
前言用overlay滤镜加水印命令不报错、输出也能播但打开一看水印偏了跑到画面外只露一角或本该贴右下角却悬在中间或被拉宽变形。这类偏移不是 FFmpeg 的 bug几乎都来自四个原因尺寸变量写混。overlay里大写W/H指主视频小写w/h指叠加图。xW-w-10是主视频宽 - 水印宽 - 10正确xw-w-10恒等于-10水印跑到左边界外。SARSample Aspect Ratio采样宽高比不一致。水印图和视频 SAR 不同时播放器为对齐显示比例会整体缩放看起来像位置变了。旋转元数据没处理。手机竖屏视频带rotate90你按看到的画面量坐标FFmpeg 按存储像素去贴差了一次旋转。坐标算的是缩放后尺寸实际用的是缩放前尺寸。本文按overlay 坐标系 → SAR 与旋转 → 完整 PHP 封装的顺序讲命令和代码都可直接跑。示例基于 PHP 8.1 与 FFmpeg 4.0overlay很早就有但参数表达式 4.x 后才稳定。一、overlay 的坐标系大写和小写的区别最核心也最容易记错的一点变量指代举例主视频 1920x1080水印 200x80W主视频宽度1920H主视频高度1080w叠加图宽度200h叠加图高度80x/y叠加图左上角在主视频里的坐标位置参数四角写法overlayx10:y10 # 左上 overlayxW-w-10:y10 # 右上 overlayx10:yH-h-10 # 左下 overlayxW-w-10:yH-h-10 # 右下最常用心算校验把 W/H/w/h 代进具体数字看结果是否落在 [0, W-w] 与 [0, H-h] 内。上例x1920-200-101710而W-w1720在范围内正确。overlay的几个有用选项选项默认作用x/y0位置表达式支持t时间秒做动态移动formatyuv420覆盖层像素格式颜色发灰/半透明发黑先怀疑它eof_actionrepeat水印图先播完时行为可选repeat/endall/passshortest0设为1时任一输入结束就结束alphastraightalpha 通道处理premultiplied用于预乘 alpha 的素材enable无时间表达式控制水印出现的时段shortest1与eof_action的组合是偏移的另一个隐蔽来源水印是单帧 PNG 时会被repeat重复到尾没问题但水印是短视频且没设shortest1时输出时长会被水印视频拉长或截短你会觉得整段视频时间轴歪了。二、偏移的三大真凶2.1 SAR 不一致看起来位置偏了其实是比例偏了每个视频流都带 SAR 字段显示宽高比DAR 存储宽高比 × SAR视频1920x1080、SAR1:1→DAR16:9正常。某些 DVD/老素材转来的视频720x480、SAR32:27→DAR≈16:9。存储是 720x480显示成 16:9。若水印SAR1:1把它overlay到SAR32:27的视频上FFmpeg 需统一比例。不同版本策略不同结果是水印被横向拉长、或整幅画面被缩放你量好的坐标全部失效。统一解法是在 filter 链最后强制setsar1缩放时显式指定尺寸。ffmpeg -i input.mp4 -i logo.png \ -filter_complex \ [0:v]scale1920:1080,setsar1[base]; \ [1:v]scale200:-1,setsar1[wm]; \ [base][wm]overlayxW-w-10:yH-h-10[out] \ -map [out] -map 0:a? \ -c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p -c:a copy \ output.mp4要点scale200:-1宽定 200高按比例自动算。-1表示自动算但必须整除比它更严格的是-2算出奇数高度会报错推荐一律用-2。setsar1加在每一路缩放之后所有支路 SAR 归一化overlay时无比例歧义。想保留原始分辨率就不写scale只写setsar1[0:v]setsar1[base]; [1:v]setsar1[wm]; [base][wm]overlay...。2.2 旋转元数据坐标系差了一次旋转手机竖屏视频通常存成横向的1920x1080再加rotate90或rotate-90元数据。播放器看到元数据会自动旋转显示所以你看到的是1080x1920竖屏画面。ffprobe读出的width/height是存储尺寸不是显示尺寸。于是你按竖屏画面量出水印在右下角坐标约 (900, 1700)FFmpeg 拿到的画面是1920x1080y1700直接越界要么把水印丢到画面外要么报错。两种正确做法# 做法 A把旋转固化掉再处理推荐后续坐标都按看到的方向量 ffmpeg -i input.mp4 -c copy -metadata:s:v:0 rotate0 -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]transpose1[base];[1:v]scale200:-2[wm];[base][wm]overlayW-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再翻转一次。对rotate90用transpose1对rotate-90/rotate270用transpose2——错了视频就是倒的比位置偏移更显眼但一眼能看出来。2.3 位置表达式写错overlayxw-w-10:yh-h-10 # ❌ 恒等于 -10水印跑到左边界外 overlayxW-w:yH-h # ❌ 少了边距很多播放器会裁掉最外圈像素 overlayxW-w-10:yH-h-10 # ✅ overlayx(W-w)/2:y(H-h)/2 # ✅ 居中还有个更隐蔽的错误表达式里的引号被 shell 吃掉了。在 PHP 里拼命令时若没正确转义(W-w)/2的括号会被 bash 当成子 shell 语法命令直接报错或被替换成空串。三、PHP 封装一次把三件事都做对下面代码完整可运行。设计原则在 PHP 侧把分辨率、SAR、旋转全读出来算清参数再交给 FFmpeg而不是把表达式丢给 FFmpeg 现场算。?php declare(strict_types1); // 视频加水印不偏移版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, streamwidth,height,sample_aspect_ratio:stream_side_datarotation, -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主视频按需旋转 强制 SAR1水印缩放/透明处理 $baseFilters []; if ($rotated) { $baseFilters[] $vmeta[rotation] 90 ? transpose1 : transpose2; } $baseFilters[] setsar1; $logoFilters []; if ($scaleWidth 0) { $logoFilters[] scale{$scaleWidth}:-2; // -2 保证偶数高度 } $logoFilters[] setsar1; if ($opacity 100) { $alpha round($opacity / 100, 2); $logoFilters[] formatrgba,colorchannelmixeraa{$alpha}; } $filter [0:v] . implode(,, $baseFilters) . [base]; . [1:v] . implode(,, $logoFilters) . [wm]; . sprintf([base][wm]overlayx%d:y%d:formatauto:shortest1[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 liststring $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 里判断画面实际方向决定transpose1还是transpose2并用交换后的基准尺寸算坐标。SAR每路都加setsar1overlay两侧比例归一。坐标系坐标在 PHP 里用具体数字算好$baseW - $wmW - $margin传进overlay的是x1710这样的常量而非W-w-10表达式从根本上不存在大小写写混的可能。注意overlay...:formatauto。默认的formatyuv420会把带 alpha 的水印强行转成不带 alpha 的格式透明背景变成黑色方块。水印 logo 一定要用带透明通道的 PNG并把format设为auto。常见坑点1.w/h和W/H写反❌overlayxw-w-10:yh-h-10恒等于(-10,-10)水印跑到左上角外输出里什么都看不到。 ✅ 记住大写是主视频、小写是叠加图xW-w-10:yH-h-10。2. 水印 PNG 没有透明通道变成黑块❌ 用 JPG 当水印或截图工具存的看起来背景是白的PNG —— 白色背景会实打实盖住画面。 ✅ 用绘图工具导出带 alpha 通道的 PNG并在overlay里加formatauto。判断方法ffprobe看pix_fmtrgba/pal8带 alphargb24不带。3. 忘记setsar1❌ 视频SAR32:27、水印SAR1:1直接overlay—— 整幅画面被横向拉伸水印跟着变宽看起来位置偏了实际是被拉伸了。 ✅ 每个缩放分支后都加setsar1。4. 竖屏视频坐标全错❌ 直接按看到的竖屏画面量坐标喂给 FFmpeg —— 存储坐标系是旋转前的y值会越界。 ✅ 先ffprobe读side_data_list的rotation在 PHP 里交换基准宽高再算坐标并在滤镜链里transpose。5. 同一条命令里混用-vf和-filter_complex❌-vf scale1280:-2与-filter_complex ...[out]同时出现 —— 两个滤镜图打架行为不确定常见结果是-vf被忽略你以为的缩放根本没发生坐标自然全错。 ✅ 只要涉及两个输入就统一只用-filter_complex把缩放写进链里[0:v]scale1280:-2,setsar1[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 不一致每个分支后加setsar1坐标怎么调都对不上旋转元数据没处理读rotation交换宽高 transpose水印背景变黑色方块水印无 alpha 或formatyuv420用带 alpha 的 PNG formatauto位置看起来偏了几像素算的是缩放前尺寸把计算挪到 PHP 里用缩放后的数值视频时长异常水印先结束且未设shortest加shortest1避免水印偏移的根本办法只有一个不要在 FFmpeg 表达式里做算术在 PHP 里把最终坐标算成确定的整数再传进去。表达式写在命令行里既无法断点调试也无法提交前校验写在 PHP 里越界、比例、旋转三类问题都能在调用 FFmpeg 之前抛异常拦下来报错明确排查从半小时缩短到一眼。