
简介这套PHP双码率视频云转码服务系统源码面向需要自建视频转码、切片及分发服务的开发者与中小型技术团队专注解决多码率输出、m3u8切片与快速切片等场景需求。其优化了双码率转码与切片流程开启双码率时也能极速转码并修复了水印开启失败、防盗失效等常见问题同时支持外部调用上传便于与现有业务系统对接。资源包共83.19MB、474个文件以225个php源码文件为主辅以61个html页面、27个js脚本及css、ts等前端与样式资源并内置ffmpeg/ffprobe转码程序、m3u8切片示例与mp4测试片段可直接部署运行或二次开发。目前已有259人学习这套完全开源且附带媒体示例与字幕样式的代码包适合希望快速搭建视频转码私有服务或深入理解切片机制的开发者作为参考。1. 双码率 m3u8 云转码的“秒切”到底解决什么问题拿到任何一套视频转码服务第一眼要看的不是管理后台而是它能不能在观众点开播放器的 300 毫秒内交出第一帧画面。一套“PHP双码率视频云转码服务系统”本质是用 PHP 编排任务、FFmpeg 做转码引擎把上传的原始视频处理成高清、标清两路 HLS 流再以 m3u8 切片形式交给 CDN 分发。“秒切”有三层含义一是第一个 .ts 切片尽快产出播放器秒开二是两路码率播放中按带宽无缝切换不黑屏不重缓冲三是基于已成片 HLS 做列表级剪辑不动转码引擎就秒级出片。这套方案适合自建点播平台的团队下文按调度设计、切片参数、秒切链路、排错验证四段推进。2. PHP 视频云转码的任务队列与 FFmpeg 双码率命令生成2.1 为什么 PHP 只做编排不做转码视频云转码的第一步是把边界划清楚PHP-FPM 的短生命周期进程不适合直接跑转码。一个 2 小时的 1080p 视频双码率转码加切片在普通云主机上要跑 10 到 30 分钟如果让 Nginx 的请求进程等待这个结果要么超时要么把 FPM 连接池占满。常见做法是 PHP 只负责两件事写任务、收结果转码本体由常驻 CLI worker 执行队列用 Redis List 或 Beanstalkd任务量不大时用 MySQL 任务表加轮询也能跑。选队列时有个容易忽略的点FFmpeg 的 CPU 占用是突发式的队列本身不背这个锅真正要做的是并发控制。我一般会让 worker 固定开 2 到 4 个进程每个 worker 同一时刻只拉起一个 FFmpeg避免 CPU 抢占把整体转码时间拖长。这个并发数按机器核数和转码规格调不要照抄默认值。2.2 最小可跑的双码率 FFmpeg 转码切片命令双码率的最小编排是两条 FFmpeg 命令一条出高清 HLS一条出标清 HLS。下面的命令在 Ubuntu 22.04 FFmpeg 4.4 环境可以直接跑只保留一条 AAC 音轨符合点播场景标清那路把 scale 改为 -2:720、b:v 改为 2000k、输出目录换成 sd 子目录即可。# 高清路1080p / 4000k4 秒一个切片点播 VOD 模式 ffmpeg -y -i /data/input/demo.mp4 \ -map 0:v:0 -map 0:a:0 \ -vf scale-2:1080 -c:v libx264 \ -profile:v high -preset veryfast \ -b:v 4000k -maxrate 4500k -bufsize 8000k \ -c:a aac -b:a 128k -ac 2 \ -force_key_frames expr:gte(t,n_forced*4) \ -hls_time 4 -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename /data/out/hd/seg_%04d.ts \ /data/out/hd/index.m3u8这条命令里最关键的三个参数是 -force_key_frames、-hls_time 和 -hls_list_size。前者强制每 4 秒一个 IDR 关键帧中间那个把每个切片的目标时长固定为 4 秒二者配对才能保证每个 ts 都以完整关键帧开头-hls_list_size 0 表示索引保留全部分片并写入 ENDLIST符合点播语义。preset 用 veryfast 而不是 slow是为了在转码吞吐和码率效率之间取平衡如果对画质更敏感可以换成 crf 18 到 23 的恒定质量模式但这时码率上限不可控ABR 带宽预估值会失真双码率场景不建议混用。2.3 PHP 侧的任务状态机与失败重试worker 拿到任务后要能回答三个问题转码到哪一步了、失败了没有、失败能不能重试。生产里我维护一个最小状态机pending → running → donerunning 出去只有 done 或 failedfailed 允许重试三次再失败进死信队列人工处理。$redis-lPush(transcode:queue, json_encode([ task_id md5($srcPath . microtime()), // 唯一任务号重试时用它做幂等 src $srcPath, steps [hd, sd], // 双码率两路变体 notify https://api.example.com/hls/notify, ], JSON_UNESCAPED_SLASHES));worker 用 proc_open 拉起 FFmpeg把 stderr 实时追加到按 task_id 命名的日志文件exit code 非 0 时把日志尾部回写任务表作为失败原因。重试要区分可恢复和不可恢复源文件损坏重试多少次都没用属于致命错误直接进死信磁盘写满、资源临时不可用这类环境问题才值得重试。判断依据看 stderr出现 No space left on device、Permission denied 就标 fatal。任务状态建议全放 Redis字段含义如下字段类型说明task_idstring全局唯一任务号回调与死信都靠它关联statusstringpending / running / done / failedretryint当前重试次数超过 3 次进死信队列ffmpeg_pidint子进程 PIDworker 崩溃后用于回收孤儿进程3. m3u8 切片参数标定与双码率索引文件生成3.1 切片时长与关键帧先搞清 m3u8 转换失败的源头m3u8 的播放单元是 ts 切片播放器拿到索引后按顺序请求每个切片。后端只保证“有切片”不够还要保证每个切片都以完整关键帧开头否则两片交界处会出现花屏或解码失败。很多 m3u8 视频转换失败就栽在这里源视频关键帧间隔是 10 秒你却用 -hls_time 4 去切FFmpeg 只能就近找 IDR 帧凑数切片实际时长变成 8 秒、10 秒、12 秒乱跳播放器预加载长度算不准自然卡顿、起播失败。要让切片长度稳定转码时必须显式拉起关键帧节奏。-force_key_frames expr:gte(t,n_forced*4) 的意思是从第 0 秒起每 4 秒强制一个 IDR 帧。配合 -hls_time 4每个切片内正好包含一个完整关键帧时长误差控制在几十毫秒内。如果你的播放器兼容性要求更高比如需要 2 秒 GOP那 hls_time 和强制关键帧表达式要同步改成 2二者必须配对。提示force_key_frames 与 hls_time 必须同步修改。只改 hls_time 不改关键帧节奏切片时长照样漂移。切片参数建议固定成一张表放在配置中心避免每个工单各写一套参数推荐值说明hls_time4切片目标时长必须与关键帧间隔一致hls_list_size00 表示保留全部分片点播用hls_playlist_typevod点播固定写入 ENDLISTforce_key_framesgte(t,n_forced*4)每 4 秒一个 IDR 帧3.2 用 PHP 生成双码率主索引文件两路子索引生成后还需要一个主索引master playlist把它们聚合起来。HLS 的 ABR 机制是播放器先请求 master.m3u8读到多个 EXT-X-STREAM-INF 条目后按带宽估算选一路子流加载。主索引不需要 FFmpeg 参与PHP 拼文本就行但带宽值要算对。function buildMaster(array $variants): string { $lines [#EXTM3U, #EXT-X-VERSION:3, #EXT-X-INDEPENDENT-SEGMENTS]; foreach ($variants as $name $v) { $lines[] sprintf( #EXT-X-STREAM-INF:BANDWIDTH%d,AVERAGE-BANDWIDTH%d,RESOLUTION%s, $v[bandwidth], $v[avgBandwidth], $v[width] . x . $v[height] ); $lines[] $name . /index.m3u8; // 相对路径和子索引同域时最稳 } return implode(\n, $lines) . \n; }调用时传两路变体hd 的 BANDWIDTH 填 4600000即视频 4000k 加音频 128k 再加容器余量sd 填 2300000。BANDWIDTH 表示码率峰值而不是平均值填低了会导致网络波动时播放器切到超出带宽的高码率流然后反复缓冲这是双码率切换体验差的头号原因。AVERAGE-BANDWIDTH 来自 RFC 8216主流播放器都拿它做平滑带宽估算有条件就一起带上如果源音频编码不是 AACCODECS 字段要按实际编码改否则部分播放器在切换瞬间会报解码错误。3.3 目录隔离、切片命名与 m3u8 索引的更新时机每个任务在输出根目录下占一个独立子目录比如 /hls/task_123/master.m3u8下面再分 hd/ 和 sd/ 两个子目录各自放 seg_0000.ts 和 index.m3u8。四位零填充的命名保证字典序和播放顺序完全一致也见过用 unix 时间戳命名的排查问题时可读性差很多不建议。索引文件的更新时机容易被忽略。FFmpeg 写 index.m3u8 是先截断再写如果 CDN 正在回源这个文件、播放器正在请求它就存在读到半截内容的窗口。常见做法是让 FFmpeg 直接写最终文件因为 done 之前索引本来就处于频繁 update 状态CDN 层对 .m3u8 做 30 秒以上的缓存就能避开写入窗口更严格的做法是转码到临时目录全部完成后用 PHP 的 rename 原子替换到正式目录代价是多一次文件移动。源码组织上这套逻辑应单独拆成 HlsOutput 类把目录规划、命名策略和原子发布封装在一起别散落在各处。4. 秒切的落地链路首片优先、预转码与播放器切换4.1 秒开的前提先看到首片而不是等全片很多团队把“转码完成”当成可以播放的信号这恰恰和秒切相悖。用户上传后想立刻看到的是预览画面不是进度条。要做到这一点核心是把任务拆成“首片就绪即通知”而不是“全片完成再通知”。worker 每生成一个切片就检查一次文件名第一个 seg_0000.ts 落盘后立刻触发回调把 hd 子索引 URL 推送出去API 层收到回调后再告诉前端“可播放了”。半成品状态下 hd/index.m3u8 里只有一两个分片播放器播完列表会停所以前端要配合轮询或者 SSE 刷新索引。更稳的做法是 FFmpeg 加 -hls_flags append_list让新切片只追加不重写列表worker 端按 filemtime 变化触发轻量通知播放器重新拉取索引后继续往下播全程无黑屏。这个方案把“秒开”变成了可验证的指标第一个切片落盘时间就是用户可播放时间的上限。4.2 预转码与先 SD 后 HD 的输出顺序如果连 4 秒切片都觉得慢就把预转码提到上传链路里。流程很简单上传完成立即入队用最快参数先出标清流720p、2000k标清首片就绪就让用户开始看高清流在后台慢慢补齐。为什么先出 SD 而不是 HD同一份源在相同 preset 下低分辨率编码快 2 到 3 倍4 秒切片能在 1 到 2 秒内落盘配合首片通知基本达到“传完就能看”的体感。这个策略的本质是把观看路径和转码路径解耦用户看的永远是最先就绪的那路流后续列表里再追加另一路。两个细节要盯住一是 master.m3u8 可以先只含 SD 流HD 就绪后用原子写更新加入条目播放器重载索引会自动发现新流二是两路切片的时间戳基准必须一致FFmpeg 默认从 0 计数但如果源文件自带时间戳偏移要加 -copyts 保持对齐否则切换瞬间时间轴会跳变表现为进度条突然回退或快进。注意先 SD 后 HD 的前提是两路切片时间戳基准一致否则用户从 SD 切到 HD 时进度条会回跳。4.3 播放器侧的双码率切换参数服务端把 master.m3u8、双码率子索引、切片都备齐后秒切的下半场在播放器。以 hls.js 为例默认 ABR 逻辑按带宽估算选变体但初始估算不准会导致首屏选错路、几秒后又跳变体验反而更差。生产环境我这样初始化const hls new Hls({ maxBufferLength: 10, abrEwmaDefaultEstimate: 1500000, // 首屏带宽猜测值按目标用户常见带宽填 startLevel: -1, // -1 自动选择指定数字则强制首屏走某路 smoothQualitySwitch: true // 在切片边界切换码率不中断当前切片 }); hls.loadSource(/hls/task_123/master.m3u8); hls.attachMedia(videoEl);abrEwmaDefaultEstimate 是首屏的带宽初始估计填目标用户最常见的下行带宽startLevel 设为 -1 表示自动填具体数字表示强制从该路起播适合刚上传完只有 SD 就绪的场景。smoothQualitySwitch 开启后码率切换发生在切片边界黑屏概率大幅下降。在 Vue 项目里这段逻辑放在播放器组件的 mounted 中beforeUnmount 里必须调 hls.destroy()否则路由切换后实例的加载定时器还在跑会出现“m3u8 被隐藏了”的假断流——其实是被销毁组件的残留请求在抢带宽、占连接。4.4 列表级剪辑不动转码引擎的秒切出片如果用户只是想要某个时间段的一个片段根本不需要重新转码。只要切片号可算、关键帧对齐PHP 就能直接生成一条引用已有 ts 的子列表这就是列表切片的典型用法$startSeg intdiv($startTime, $segmentDuration); // 起始秒数换算成切片号 $endSeg intdiv($endTime, $segmentDuration); $lines [#EXTM3U, #EXT-X-VERSION:3, #EXT-X-TARGETDURATION:4]; for ($i $startSeg; $i $endSeg; $i) { $lines[] #EXTINF:4.0,; $lines[] sprintf(hd/seg_%04d.ts, $i); // 引用已有切片零拷贝 } $lines[] #EXT-X-ENDLIST; file_put_contents(/hls/task_123/clip_1.m3u8, implode(\n, $lines) . \n);这段代码的精度边界要讲清楚列表级剪辑只能切到切片边界无法精确到秒内任意帧想精确到某个整秒必须保证强制关键帧正好落在目标秒数上。这也是为什么前面把关键帧对齐做成基础设施而不是可选项——它同时服务了转码质量、码率切换和精准剪辑三个需求。5. 验证与排错m3u8 转码失败的三个高频根因5.1 转码完成后先跑 ffprobe 验证不验证就上线等于埋雷。任务 done 之后worker 顺手跑一次 ffprobe把实际切片时长和分辨率打成 JSON 存档ffprobe -v error -show_entries formatduration -show_entries streamcodec_name,width,height -of json /data/out/hd/index.m3u8输出的 duration 应该和任务预期总时长误差在 2 秒以内width/height 与双码率规格一致再抽查首片、中段、末片三个 ts 的时长都在 4 秒 ±10% 内才算切片节奏达标。5.2 根因一关键帧间隔大于切片时长分片时长漂移症状是索引里各分片 EXTINF 数值相差超过 50%部分播放器直接拒绝整条流。根源就是 3.1 节说的 GOP 偏长、切片偏短FFmpeg 只能凑就近关键帧落点。转码历史遗留文件时尤其常见。修复没有捷径重新执行带 -force_key_frames 的转码保证 hls_time 与关键帧节奏一致。5.3 根因二Nginx 对 m3u8 和 ts 返回了错误 Content-Type症状是播放器报 MEDIA_ERR_SRC_NOT_SUPPORTED浏览器看 manifest 请求 200 但 Content-Type 是 application/octet-stream。检查 nginx 配置确保有这两条映射types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }跨域和防盗链也常在这一层翻车播放页和 /hls/ 不同域时m3u8 响应要带 Access-Control-Allow-Origin用了 referer 防盗链要把播放页域名加白否则会出现间歇性“m3u8 被隐藏了”的假象其实是部分请求 Referer 为空被拦掉了。5.4 根因三VOD 与 EVENT 列表语义用反-hls_playlist_type 有三个取值vod 写入 ENDLIST适合固定文件event 持续追加但不写 ENDLISTlive 带滑动窗口配合 hls_list_size 控制保留分片。常见误用是把 event 或 live 用在点播上播放器播完最后一个切片后等不到 ENDLIST停在黑屏转圈。点播双码率场景固定用 vod 加 hls_list_size 0不会踩这个坑。本文还有配套的精品资源点击获取