PHP怎么解决ffmpeg转码后视频音画不同步 前言音画不同步A/V syncaudio/video synchronization有两种典型形态处理思路完全不同。第一种是恒定偏移全片的声音一直比画面早或晚固定的几百毫秒。常见于拼接、切片、抽帧之后原因是两轨的起始时间戳start_time不相等。第二种是线性漂移开头对得上越到后面偏得越多看到片尾可能差好几秒。原因通常是源文件是变帧率VFRvariable frame rate手机录屏、屏幕录制很常见或者音频采样率与视频帧率之间存在累积误差。很多人遇到不同步的第一反应是「用-itsoffset把音频推一下」。这在恒定偏移上有效但在线性漂移上完全无用——偏移量随时间变化一个常量补偿不了。所以正确的顺序永远是先用 ffprobe 量化判断属于哪一种再选参数。本文先用 ffprobe 建立量化判断标准再讲四种成因对应的处理方案最后给出一份可以直接运行的 PHP 诊断 修复脚本PHP 7.4 及以上均可运行不依赖任何 Composer 包。一、先量化不同步属于哪一种判断依据只有两个数视频轨与音频轨的start_time之差以及两者duration之差。ffprobe -v error \ -show_entries streamindex,codec_type,start_time,duration,avg_frame_rate,r_frame_rate,nb_frames \ -show_entries formatduration \ -of json input.mp4读法如下现象判断处理方向start_time差别明显几百毫秒以上恒定偏移对齐起点-avoid_negative_ts make_zero或-itsoffset两轨duration差超过约一帧的时长线性漂移锁定帧率/采样率-fps_mode cfraresampleasync1两者都正常但播放仍不同步时间基或封装问题检查-copyts、-video_track_timescale、容器封装只有部分播放器不同步播放器对时间戳的解释差异重写时间戳后重新封装-fflags genpts一个经验判断用「差值是否随时间增长」来区分。把输出视频从头播到尾如果偏差不扩大就是恒定偏移如果片尾偏差明显大于开头就是漂移。二、四种成因与对应参数2.1 变帧率源转成恒定帧率手机和录屏工具生成的文件常常是 VFR帧与帧之间的间隔不固定但每帧自带时间戳。一旦播放器或转码器按恒定帧率CFR去理解它画面就会被「均匀化」而音频不受影响于是产生漂移。处理方式是把时间基准显式钉死到恒定帧率-fps_mode cfr -r 30新版 ffmpeg 使用-fps_mode更早的版本用-vsync cfr后者会打印弃用警告但通常仍可用。用ffmpeg -h full可以确认当前版本支持哪个写法。2.2 音频时间戳与视频起点不一致音频处理链在重采样时会重新计算时间戳有时会让音频起点不再是 0。标准做法是让重采样器把首个音频样本对齐到零点-af aresampleasync1:first_pts0async1允许重采样器在必要时插入或丢弃样本以维持同步first_pts0强制首个样本的时间戳归零。注意不要再用老的-async 1它已被弃用且语义与aresampleasync并不等价混用只会让问题更难复现。2.3 时间戳为负或起点不为零解码器输出负时间戳是正常现象B 帧与编辑列表导致。封装到 MP4 时负时间戳会让部分播放器从「负数位置」开始解释表现出来就是开头声音对不上。两种处理# 方案 A让 ffmpeg 丢弃原始时间戳、按帧序重新生成 -fflags genpts -avoid_negative_ts make_zero # 方案 B保留原始时间戳但把输出起点平移到 0 -copyts -start_at_zero这两个方案不要混着用。-copyts保留原始时间戳-avoid_negative_ts是重写时间戳同时给出会让结果依赖参数顺序非常难排查。选一个并且在转码后用 ffprobe 复核。2.4 分段处理后拼接把长视频切成多段分别转码再拼接是偏移的重灾区。原因是每段各自被重写了时间戳拼接后段与段之间会出现跳变。两个可行做法所有分段用完全相同的编码参数帧率、采样率、时间基、GOP拼接时用 concat 解复用器。或者先统一转成中间格式例如恒定帧率的 MPEG-TS再拼接。如果分段参数不一致拼接后的产物会出现「每一段都不同步一点」的现象看起来像随机故障。三、完整可运行的诊断 修复脚本下面这份代码只用 PHP 标准库不依赖 Composer 包。它会先量化再给出判断然后执行修复并复核结果。?php declare(strict_types1); // av_sync.php —— PHP 7.4 及以上均可运行 // 用法: php av_sync.php input.mp4 output.mp4 /** * 用数组形式调用 proc_openPHP 7.4 支持不经过 shell * 因此路径里的空格、引号都不会造成问题。 */ function run(array $cmd): array { $desc [1 [pipe, w], 2 [pipe, w]]; $proc proc_open($cmd, $desc, $pipes); if (!is_resource($proc)) { throw new RuntimeException(无法启动进程: . $cmd[0]); } $out stream_get_contents($pipes[1]); $err stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code proc_close($proc); return [$code, $out, $err]; } /** 取出视频轨、音频轨的起点与时长 */ function probeTracks(string $file): array { [$code, $out, $err] run([ ffprobe, -v, error, -show_entries, streamindex,codec_type,start_time,duration,avg_frame_rate, -show_entries, formatduration, -of, json, $file, ]); if ($code ! 0) { throw new RuntimeException(ffprobe 失败: . trim($err)); } $data json_decode($out, true, 512, JSON_THROW_ON_ERROR); $result [ video null, audio null, format (float) ($data[format][duration] ?? 0), fps null, ]; foreach ($data[streams] as $s) { $type $s[codec_type] ?? ; if ($type ! video $type ! audio) { continue; } $result[$type] [ start (float) ($s[start_time] ?? 0), duration (float) ($s[duration] ?? 0), ]; if ($type video) { $result[fps] $s[avg_frame_rate] ?? null; // 形如 30000/1001 } } return $result; } function report(string $tag, array $t): void { $v $t[video]; $a $t[audio]; printf( %s \n, $tag); if ($v null) { echo 没有视频轨\n; return; } printf( 视频 start%.3f 时长%.3f 帧率%s\n, $v[start], $v[duration], (string) $t[fps]); if ($a null) { echo 没有音频轨不存在音画同步问题\n; return; } printf( 音频 start%.3f 时长%.3f\n, $a[start], $a[duration]); printf( 起始偏移 %.3f 秒正数表示音频晚起\n, $a[start] - $v[start]); printf( 时长差 %.3f 秒正数表示音频更长会越播越偏\n, $a[duration] - $v[duration]); } if ($argc 3) { exit(用法: php av_sync.php input.mp4 output.mp4\n); } $in $argv[1]; $out $argv[2]; $before probeTracks($in); report(转码前, $before); $v $before[video]; $a $before[audio]; if ($v null) { exit(没有视频轨无法诊断。\n); } // 分诊只看起点差 - 恒定偏移时长差随时间累积 - 漂移 $startDiff $a ! null ? abs($a[start] - $v[start]) : 0.0; $durDiff $a ! null ? abs($a[duration] - $v[duration]) : 0.0; if ($a ! null $durDiff 0.5) { echo 判断: 线性漂移为主按恒定帧率 音频重采样对齐处理。\n; } elseif ($startDiff 0.05) { echo 判断: 恒定偏移为主按时间戳起点对齐处理。\n; } else { echo 判断: 探测数据未发现明显偏移可能是封装/播放器层面的时间戳解释问题。\n; } // 修复命令参数组合按官方推荐注意 -fps_mode 与 -vsync 的版本差异 $cmd [ ffmpeg, -y, -i, $in, -fflags, genpts, -af, aresampleasync1:first_pts0, -fps_mode, cfr, -r, 30, -c:v, libx264, -preset, medium, -crf, 20, -c:a, aac, -b:a, 128k, -avoid_negative_ts, make_zero, -movflags, faststart, $out, ]; [$code, $stdout, $stderr] run($cmd); if ($code ! 0) { // 旧版 ffmpeg 不认 -fps_mode 时回退到 -vsync fwrite(STDERR, 第一次转码失败尝试兼容参数。ffmpeg 输出\n . $stderr . \n); $cmd[array_search(-fps_mode, $cmd, true)] -vsync; [$code, $stdout, $stderr] run($cmd); if ($code ! 0) { exit(转码仍失败\n . $stderr . \n); } } $after probeTracks($out); report(转码后, $after); $v2 $after[video]; $a2 $after[audio]; if ($v2 ! null $a2 ! null) { $newDurDiff abs($a2[duration] - $v2[duration]); printf(复核: 转码后时长差 %.3f 秒与原值 %.3f 秒比较越接近 0 越好\n, $newDurDiff, $durDiff); } echo 完成: {$out}\n;这段脚本的用法很直接php av_sync.php raw.mp4 fixed.mp4它把「诊断」和「修复」放在同一个流程里转码后再跑一次同样的探测并把前后数值打出来。这样你不必靠播放器感觉判断而是有一组可比较的数字。需要说明的是-fps_mode cfr -r 30里的 30 应当替换成源文件真实的帧率。如果源是 25 或 29.97强行转成 30 会引入新的丢帧/插帧问题——这也是为什么脚本里要先把avg_frame_rate打印出来它以「分子/分母」的形式给出例如30000/1001表示约 29.97。常见坑点1. 用-itsoffset处理漂移❌ffmpeg -i in.mp4 -itsoffset 0.5 -i in.mp4 -map 0:v -map 1:a out.mp4✅ 先用 ffprobe 区分偏移类型漂移类问题必须靠锁定帧率和重采样解决。-itsoffset只能施加一个常量偏移。对漂移问题它会在开头把偏差补平、在片尾把偏差放大成两倍。2. 忘记-copyts与-start_at_zero是配套的❌ 只写-copyts输出视频的起始时间戳不是 0。 ✅ 组合使用-copyts -start_at_zero。只保留原始时间戳而不平移会让部分播放器从非零位置开始计时表现出来就是开头的音画错位。3. 继续使用已弃用的-async 1❌ffmpeg -i in.mp4 -async 1 out.mp4✅-af aresampleasync1:first_pts0老参数在新版本里会打印弃用提示语义也不再是当初那个意思。混用老参数和新参数是最难排查的组合。4. 转码后不复核❌ 转完直接上线靠用户反馈发现问题。 ✅ 转码后立刻再跑一次 ffprobe对比两轨时长差。同步问题往往只有在拖动进度条或长时间播放时才暴露人工抽检很容易漏掉。5. 在-ss的输入/输出位置上搞混❌ 需要精确到帧的裁切却把-ss放在-i前面。 ✅ 追求速度用-ss在-i之前关键帧对齐快追求精确用-ss在-i之后逐帧解码慢但准。裁切点落在关键帧上会造成一个恒定偏移——因为音频和视频的关键帧间隔不同两边被裁掉的长度不一样。6. 分段转码的参数不一致❌ 第一段用-r 30第二段忘了写。 ✅ 把参数集中成一份配置所有分段共用。拼接后的偏移会以「每段累积一点」的形式出现表现为完全随机的故障实际原因却非常机械。7. 只看format.duration不看每轨时长❌ 用容器总时长判断同步情况。 ✅ 分别读视频轨与音频轨的start_time和duration。容器总时长是各轨的合并结果掩盖了「视频 100 秒、音频 102 秒」这类关键差异。8. 用exec()拼接含空格的路径❌exec(ffmpeg -i $file -c copy $out);✅ 用proc_open的数组形式PHP 7.4或对每个参数escapeshellarg()。文件名里带空格时命令会被参数解析切碎报出「找不到文件」的错而你看日志里打印的路径完全正确很容易误判成权限问题。总结成因特征关键参数变帧率源被当恒定帧率处理时长差明显越播越偏-fps_mode cfr -r 真实帧率音频首样本时间戳非零恒定偏移两轨start_time不等-af aresampleasync1:first_pts0负时间戳 / 起点不为零开头就错位-fflags genpts -avoid_negative_ts make_zero或-copyts -start_at_zero分段转码参数不一致段与段之间跳变统一全部编码参数后再拼接处理音画不同步的核心是先量化再动手用 ffprobe 读出两轨的起点与时长用「偏差是否随时间扩大」区分恒定偏移和线性漂移再选对应的参数组合。恒定偏移靠对齐时间戳漂移靠锁定帧率和重采样——两者的解法互不通用用错了只会让问题看起来更随机。最后一步的复核不能省它是唯一能证明修复确实生效的证据。