ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP怎么用ffmpeg实现视频自动去水印

PHP怎么用ffmpeg实现视频自动去水印 前言先把最重要的一句放最前面FFmpeg 没有自动识别并去除水印的能力。任何声称一键自动去水印的方案背后要么是预先框定了水印坐标要么是接了 OpenCV 之类的计算机视觉库做目标检测。搜一条命令自动去水印点进去会发现命令里明明白白写着delogox10:y10:w200:h80——这四组数字是人眼量出来的不是程序算出来的。本文讲的是在 PHP 里怎么正确调度 FFmpeg 完成去水印包括几种可用滤镜的真实效果与代价、水印坐标怎么确定、以及一个可运行的 PHP 封装同时把哪些是营销话术、哪些是技术事实分清楚让你答复需求方时不至于承诺做不到的事。去水印在技术上有三种完全不同的路径路径FFmpeg 滤镜原理真实效果区域插值delogo用区域边界一圈的像素往内插值填充纯色背景上好复杂纹理/动态背景上留下明显模糊色块蒙版修补removelogo读一张蒙版图用边缘像素加权填补比delogo细腻但需额外制作蒙版图模糊/打码boxblur、avgblur、pixelize把区域糊掉不是去除是遮蔽留下糊斑裁切crop直接裁掉含水印的边画面尺寸变小构图被破坏没有任何一种能还原被水印覆盖的原始像素。这些像素本来就被水印像素替换掉了任何方法都只能猜差别只在于猜得像不像。这是跟需求方沟通时唯一需要说清的技术事实。一、确定水印坐标这一步没有捷径delogo需要四个参数x、y水印左上角坐标和w、h水印宽高。它们怎么来方式一人工量。用播放器截一帧用图像工具读出像素坐标。最常用也最可靠。方式二固定机位/固定模板的视频把坐标写死在配置里。多数场景平台水印、摄像机 OSD 时间戳水印位置固定量一次就够。方式三接入 CV 库做检测。思路是抽取一帧 → 用边缘检测或模版匹配template matching找到水印区域 → 算出包围盒 → 喂给delogo。FFmpeg 自己不提供这一步需要 OpenCVPHP 侧可通过命令行调用或用 Python 服务。这条链路才是自动二字的真正含义而且只在水印是固定图案、背景不太乱时可靠。本文实现采用方式一 配置化把水印坐标当配置项再用 PHP 变成 FFmpeg 命令。下面这段代码可先用来抽帧方便量坐标。?php declare(strict_types1); // 抽帧小工具PHP 8.1依赖 PATH 里的 ffmpeg/ffprobe final class Ffmpeg { public function __construct( private readonly string $ffmpeg ffmpeg, private readonly string $ffprobe ffprobe, ) {} /** param liststring $args return array{code:int, stdout:string, stderr:string} */ private function run(array $args): array { $cmd implode( , array_map( static fn (string $a): string escapeshellarg($a), $args )); $p proc_open($cmd, [1 [pipe, w], 2 [pipe, w]], $pipes); if (!is_resource($p)) { throw new RuntimeException(无法启动进程: {$cmd}); } $stdout stream_get_contents($pipes[1]) ?: ; $stderr stream_get_contents($pipes[2]) ?: ; fclose($pipes[1]); fclose($pipes[2]); return [code proc_close($p), stdout $stdout, stderr $stderr]; } /** return array{width:int, height:int, duration:float, sar:string, rotation:int} */ public function probe(string $file): array { $r $this-run([ $this-ffprobe, -v, error, -select_streams, v:0, -of, json, -show_entries, streamwidth,height,sample_aspect_ratio:stream_side_datarotation:formatduration, $file, ]); if ($r[code] ! 0) { throw new RuntimeException(ffprobe 失败: . $r[stderr]); } $info json_decode($r[stdout], true, 512, JSON_THROW_ON_ERROR); $stream $info[streams][0] ?? []; $rotation 0; // 旋转角度藏在 side_data_list 里 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), duration (float) ($info[format][duration] ?? 0.0), rotation $rotation, ]; } /** 在第 $atSecond 秒抽一帧为 PNG */ public function snapshot(string $input, float $atSecond, string $output): void { $r $this-run([ $this-ffmpeg, -y, -ss, (string) $atSecond, // 放在 -i 之前 快速定位不解码前面的帧 -i, $input, -frames:v, 1, -q:v, 2, $output, ]); if ($r[code] ! 0) { throw new RuntimeException(抽帧失败: . $r[stderr]); } } } // ------------------------------------------------------------------ $ff new Ffmpeg(); $in __DIR__ . /input.mp4; $m $ff-probe($in); printf(分辨率 %dx%d | 时长 %.2fs | SAR %s | 旋转 %d 度\n, $m[width], $m[height], $m[duration], $m[sar], $m[rotation]); // 抽第 3、30、60 秒三帧确认水印位置是否固定 foreach ([3.0, 30.0, 60.0] as $i $sec) { if ($sec $m[duration]) { $ff-snapshot($in, $sec, __DIR__ . /frame_{$i}.png); } }二、delogo 参数怎么用拿到坐标后去水印命令的核心就是一条-vfvideo filter视频滤镜链ffmpeg -i input.mp4 \ -vf delogox10:y10:w200:h80:show0 \ -c:a copy \ -c:v libx264 -crf 20 -preset medium \ output.mp4参数说明参数含义注意x/y水印左上角坐标必须x w 视频宽、y h 视频高越界直接报错w/h水印区域宽高建议比水印实际尺寸各大 2~4 像素把边缘抗锯齿过渡像素包进去show设为1显示绿色框标出区域调试用上线前务必改回 0否则输出里有绿框band插值边带宽度默认1一般不用改show1是排查坐标对不对的唯一可靠手段。先用show1跑一遍肉眼确认绿框完全盖住水印、且没多圈住画面内容再跑正式版本。坐标偏 10 像素结果就是水印还剩一条边旁边还被无辜糊掉一块。水印若在动态位置比如飘动的台标delogo无能为力——它的坐标是常量表达式不能随时间变化。这时只能用boxblur/pixelize把整块区域遮蔽掉但这是打码不是去除。三、用 PHP 封装成可复用的服务?php declare(strict_types1); // 视频去水印服务PHP 8.1 final class WatermarkRemover { private Ffmpeg $ff; // 上一节定义的 Ffmpeg 类 public function __construct( private readonly string $workDir, ?Ffmpeg $ff null, private readonly string $binary ffmpeg, ) { $this-ff $ff ?? new Ffmpeg(); if (!is_dir($this-workDir)) { mkdir($this-workDir, 0755, true); } } /** * param array{x:int, y:int, w:int, h:int} $region 水印区域 * return array{code:int, stderr:string, output:string} */ public function remove( string $input, array $region, ?string $output null, bool $debugShow false, int $crf 20, ): array { if (!is_file($input)) { throw new InvalidArgumentException(输入文件不存在: {$input}); } // 越界在本地就拦住别等 ffmpeg 报错 $meta $this-ff-probe($input); $this-assertRegion($region, $meta[width], $meta[height]); $output ?? $this-workDir . / . pathinfo($input, PATHINFO_FILENAME) . _clean.mp4; $filter sprintf( delogox%d:y%d:w%d:h%d:show%d, $region[x], $region[y], $region[w], $region[h], $debugShow ? 1 : 0, ); // 参数数组每项都是独立参数不要拼成一整串 return $this-exec([ -y, -i, $input, -vf, $filter, -c:v, libx264, -crf, (string) $crf, -preset, medium, -pix_fmt, yuv420p, -c:a, copy, // 音频直接复制不重编码 -movflags, faststart, $output, ], $output); } /** 框错时把整块区域打码而不是尝试修复对动态水印是唯一可行方案。 * param array{x:int, y:int, w:int, h:int} $region */ public function blur(string $input, array $region, int $strength 10): array { $meta $this-ff-probe($input); $this-assertRegion($region, $meta[width], $meta[height]); $output $this-workDir . / . pathinfo($input, PATHINFO_FILENAME) . _blur.mp4; // crop 出区域 - 模糊 - 贴回原位 $filter sprintf( [0:v]crop%d:%d:%d:%d,boxblur%d[a];[0:v][a]overlay%d:%d[out], $region[w], $region[h], $region[x], $region[y], $strength, $region[x], $region[y], ); return $this-exec([ -y, -i, $input, -filter_complex, $filter, -map, [out], -map, 0:a?, // ? 音频不存在时也不报错 -c:v, libx264, -crf, 20, -preset, medium, -pix_fmt, yuv420p, -c:a, copy, $output, ], $output); } /** param arraystring, mixed $region */ private function assertRegion(array $region, int $width, int $height): void { foreach ([x, y, w, h] as $k) { if (!isset($region[$k]) || $region[$k] 0) { throw new InvalidArgumentException(区域参数 {$k} 非法); } } if ($region[w] 0 || $region[h] 0) { throw new InvalidArgumentException(宽高必须为正数); } if ($region[x] $region[w] $width) { throw new InvalidArgumentException(xw 超出画面宽度 {$width}); } if ($region[y] $region[h] $height) { throw new InvalidArgumentException(yh 超出画面高度 {$height}); } } /** param liststring $args return array{code:int, stderr:string, output:string} */ private function exec(array $args, string $output): array { $cmd implode( , array_map( static fn (string $a): string escapeshellarg($a), array_merge([$this-binary], $args) )); $p proc_open($cmd, [2 [pipe, w]], $pipes); if (!is_resource($p)) { throw new RuntimeException(无法启动 ffmpeg); } $stderr stream_get_contents($pipes[2]) ?: ; fclose($pipes[2]); // ffmpeg 的进度输出全在 stderr 上这是正常的不代表出错 return [code proc_close($p), stderr $stderr, output $output]; } } // ------------------------------------------------------------------ $svc new WatermarkRemover(__DIR__ . /tmp); $region [x 1040, y 620, w 204, h 84]; // 右下角 200x80 的水印 $result $svc-remove(input: __DIR__ . /input.mp4, region: $region, debugShow: false); if ($result[code] 0) { echo 处理完成: {$result[output]}\n; } else { // 只打印最后 20 行ffmpeg 完整输出很长 $lines array_slice(explode(\n, trim($result[stderr])), -20); echo 处理失败:\n, implode(\n, $lines), \n; }四、批处理与容错真实场景往往是一批文件两个问题必须处理一是失败要隔离。一个文件失败不能让整批停掉也不能默默跳过建议每个文件单独落一条记录成功/失败/失败原因前 20 行 stderr。二是超时要能杀。FFmpeg 处理长视频可能跑几十分钟用proc_open时要自己控制超时?php declare(strict_types1); // 带超时的 ffmpeg 执行PHP 8.1 /** param liststring $args return array{code:int, stderr:string, timedOut:bool} */ function runWithTimeout(array $args, int $timeoutSeconds 1800): array { $cmd implode( , array_map( static fn (string $a): string escapeshellarg($a), $args )); $p proc_open($cmd, [2 [pipe, w]], $pipes); if (!is_resource($p)) { throw new RuntimeException(无法启动进程); } stream_set_blocking($pipes[2], false); $stderr ; $deadline microtime(true) $timeoutSeconds; $timedOut false; while (proc_get_status($p)[running]) { if (microtime(true) $deadline) { proc_terminate($p, 15); // 先 SIGTERMffmpeg 会写完文件头再退 usleep(2_000_000); if (proc_get_status($p)[running]) { proc_terminate($p, 9); // 还没退就上 SIGKILL } $timedOut true; break; } $chunk stream_get_contents($pipes[2]); if ($chunk ! false) { $stderr . $chunk; } usleep(200_000); } $stderr . stream_get_contents($pipes[2]) ?: ; fclose($pipes[2]); return [code proc_close($p), stderr $stderr, timedOut $timedOut]; }proc_terminate的第二个参数是信号编号15是SIGTERM9是SIGKILL。先 SIGTERM 再 SIGKILL 是有意义的——FFmpeg 收到 SIGTERM 会尝试正常收尾直接 SIGKILL 会留下一个损坏、无法播放的 mp4你甚至看不出它是被杀的只觉得这文件怎么打不开。常见坑点1. 把用户输入拼进命令行❌exec(ffmpeg -i {$file} -vf delogo... out.mp4)—— 文件名里带; rm -rf /或$(...)就出事了空格也会让参数错位。 ✅ 用proc_open 参数数组 escapeshellarg()逐项转义。判断标准只要命令字符串用了插值就有注入风险。2. 坐标越界导致滤镜初始化失败❌ 水印在右下角直接写x1040:y620:w204:h84而视频实际是竖屏转码版本时y会超。FFmpeg 报Invalid too big or non positive size for width 204 or height 84这个报错有误导性它其实在说位置越界。 ✅ 先用ffprobe读出真实分辨率在 PHP 里校验xw width且yh height越界在本地抛异常。3. 忘了把 show1 改回 0❌ 调试时用show1确认坐标直接把这个版本发到线上。 ✅ 把show做成方法参数、默认false并在输出文件名里区分 debug 版本。绿框在缩略图上非常显眼不预览就发出去用户会先看到。4. 处理竖屏视频时坐标全错❌ 视频带rotate90元数据手机拍的ffprobe读出的width/height是旋转前的播放器显示的却是旋转后的画面。按看到的方向量坐标FFmpeg 按存储方向去裁结果完全错位。 ✅ 读side_data_list的rotation坐标要按存储方向给最稳妥是先ffmpeg -i in.mp4 -c copy -metadata:s:v rotate0 out.mp4把旋转固化掉再量。5. 音频被无谓重编码 /-vf与-filter_complex混用❌ 只写-c:v libx264不写-c:a—— FFmpeg 会用默认编码器重编码音频AAC 再压一次 AAC音质下降且耗时翻倍。 ✅ 加-c:a copy视频滤镜只影响视频流音频流没被碰过复制是最优解。 ❌-vf delogo...与-filter_complex [0:v]...overlay...同时出现 —— FFmpeg 会报错或忽略其中之一行为不确定。 ✅ 多输入/多输出时统一用-filter_complex只有一路简单滤镜时用-vf不要混用。6. 直接用exec()拿返回值当成功 / 声称无损去水印❌exec($cmd, $out, $code); if ($code) {...}——$out是空的因为没加21FFmpeg 所有信息都写到 stderr 了排查时一片空白。 ✅ 用proc_open显式接管 stderr并记录最后若干行 stderr这是唯一的排错依据。 ❌ 对客户说我们能无痕去除画质不受影响。delogo是用周边像素插值覆盖属于有损处理换个背景就会看出模糊块动态水印、半透明水印、复杂纹理上的水印都会有肉眼可见痕迹。承诺做不到的事最后赔的是交付。总结需求手段适用性固定位置、纯色背景水印delogo效果好固定位置、复杂背景removelogo 蒙版图需要额外制作蒙版动态位置水印boxblur/pixelize遮蔽无法真正去除边角水印crop裁掉改变画面尺寸全自动识别FFmpeg 做不到需 OpenCV 等 CV 方案仅固定图案可靠FFmpeg 在去水印里的角色是执行器不是识别器。它负责把已确定的坐标变成像素操作识别这件事从头到尾都得由你或一个 CV 服务完成。PHP 侧要做好的三件事坐标校验前置、命令参数安全转义、超时与失败隔离。还有一件比技术更优先的事去水印涉及著作权和平台协议不是所有视频都适合做这个处理。交付前先确认使用场景的合法性这比滤镜参数重要得多。
返回列表