简介:这是一套面向PHP开发者的双码率视频云转码服务系统源码,用于搭建支持m3u8切片、秒切与防盗链保护的在线转码平台,适合有视频业务需求或希望深入理解视频处理流程的中高级开发者。压缩包内共474个文件,包体83.19MB,核心以225个PHP文件为主,辅以HTML页面、JavaScript/TypeScript脚本、CSS样式,并集成ffmpeg、ffprobe可执行程序、m3u8切片示例和mp4测试视频,便于本地部署与二次开发。源码已修复双码率转码切片、四路水印开启失效、防盗功能失效等问题,优化后可在双码率下实现极速转码与秒切,同时支持外部接口调用上传。系统完全开源,目录结构清晰,可自由二开,适合私有化视频转码服务,也可作为学习视频处理、m3u8切片封装技术的参考项目。目前已有259人学习下载。

1. 双码率视频云转码服务系统的关键,是“秒切”而不只是转码

运营后台传了一条 1.2GB 的视频,转码任务提交后页面转圈四分钟才显示完成。用户其实不在乎 1080p 什么时候就绪,在乎的是预览能不能赶紧放出来。视频云转码服务里最容易被低估的指标是首片输出时间,也就是标题中 m3u8 切片秒切这个能力要解决的问题。这套 PHP 方案做的事情是:把视频源转成 H.264/AAC,按双码率各自切成 ts 分片,用 m3u8 索引串成可流式播放的 HLS,再把最耗时的任务从请求进程挪到 CLI worker,先出低码率切片,后补高清切片。适合想用 PHP 承接视频转码、又不想引入 Java/Go 重服务的中小型团队。

2. m3u8 双码率切片的核心逻辑:索引结构、GOP 对齐与编码选型

2.1 转码前先探测源文件编码,决定是转码还是流复制

上传的视频不都是 H.264。很多手机录屏、专业相机出来的素材是 H.265,浏览器和大部分播放器不认,直接切片会让 m3u8 视频转换失败的比例居高不下。我一般会在任务入队前先跑一次 ffprobe:

ffprobe -v error \
  -show_entries stream=index,codec_type,codec_name,profile,width,height,pix_fmt,sample_rate \
  -of default=noprint_wrappers=1 /data/origin/xxx.mp4

输出会给出每个流的编码信息。判断逻辑:视频流 codec_name 不是 h264 就必须走完整转码;是 H.264 但 profile 是 High 10 bit 或 pix_fmt 不是 yuv420p ,也要重编码,否则部分浏览器解码会出现绿屏。音频流只要不是 aac 或 mp3 就统一转 aac ,采样率统一成 48000 或 44100,避免后面切片时出现音频时间戳不匹配。建议把探测结果 JSON 化存进任务表,转码时直接用,省一次重复探测。只有源文件本身就是 H.264 High Profile、AAC、yuv420p、高宽都是偶数时,才考虑 -c copy 流复制做纯切片,CPU 开销几乎为零。

2.2 主播放列表与媒体播放列表的分工,双码率靠 #EXT-X-STREAM-INF 串联

HLS 的标准结构是两层索引。顶层叫主播放列表(master playlist),里面不写 ts 文件,只写两路子列表的地址与带宽描述;真正指向 ts 切片的是媒体播放列表(media playlist)。播放器根据带宽在两路流之间切换,这就是双码率的实际工作方式。一套典型的 master.m3u8 长这样:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=2200000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
low/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.4d401f,mp4a.40.2"
high/index.m3u8

BANDWIDTH 表示该路流期望占用的峰值带宽,播放器按这个值选路; RESOLUTION 是分辨率。 CODECS 标注编码参数,可以先用 ffprobe 输出的 profile 与 level 拼出来,不要手工硬编码,iOS 原生播放器对这个字段校验较严。两个 index.m3u8 各自维护自己的 .ts 列表,切片文件名不要跨码率共用,否则播放器从 720p 切到 1080p 时可能拿到分辨率不一致的旧分片。生成 master 文件最简单的时机是两路媒体播放列表都已就绪后由 PHP 写入;转码期间,播放器直接访问低码率的 low/index.m3u8 即可。

2.3 切片时长与 GOP 对齐:从 2 秒到 6 秒怎么选

切片时长( hls_time )决定首屏等待时间与切片数量。切片越长,索引越小、请求数越少,但每个 ts 首播前必须等整个分片下载完,弱网下体验明显变差。常见做法是 4 秒切片用于常规点播,2 秒切片用于直播和秒切预览,6 秒用于大文件、低切片数优先的归档场景。参数选择背后还要考虑关键帧对齐:

hls_time 建议 GOP 适用场景 首片产出预估
2 秒 2 秒内一个关键帧 快速预览、短视频点播 2-3 秒
4 秒 4 秒内一个关键帧 常规业务点播 4-6 秒
6 秒 6 秒内一个关键帧 大文件、低切片数优先 6-8 秒

关键帧间隔必须能被切片时长整除,否则切片边界会落在非关键帧位置,播放器拉到下一片时得等下一个关键帧,画面卡住半秒到一秒。转码命令里用 -force_key_frames "expr:gte(t,n_forced*4)" 每 4 秒强制一个关键帧,配合 -sc_threshold 0 关闭场景切割,片头时间才能稳定。编码级别方面,浏览器兼容底线是 main profile + level 3.1;1080p 高帧率可选 high profile,但国内大量 Android WebView 播放器对 main 支持更稳。

3. PHP 转码任务调度链:任务表、CLI worker 与 FFmpeg 进程管理

3.1 PHP-FPM 不适合长任务,转码进程必须跑在 CLI

这套系统的代码层通常分三块:PHP-FPM 负责接上传和写任务、CLI worker 负责跑 FFmpeg、Nginx 负责把切好的文件和索引发出去。很多 PHP 视频转码方案被吐槽,问题出在 exec 一把梭。在 PHP-FPM 里直接调 FFmpeg 有两个硬伤:一是 max_execution_time 默认 30 秒,转一条十分钟的视频必然超时,改大又拖垮同进程下的其它请求;二是用户关闭浏览器后 Nginx 断开连接,PHP-FPM 可能回收 worker,正在转的 FFmpeg 子进程被连坐终止。

正确做法是后台任务化:PHP-FPM 只接收上传、写任务表、往 Redis 投递任务;CLI worker 常驻消费队列,与请求生命周期完全隔离。worker 用 brPop 阻塞读队列,没有任务时 CPU 占用几乎为零:

<?php
declare(strict_types=1);

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

while (true) {
    $job = $redis->brPop(['transcode:queue'], 5);
    if (!$job) {
        continue;
    }

    $task = json_decode($job[1], true);
    $taskId = (int)$task['id'];

    // 抢占:只有 pending 状态的任务能抢到
    $claimed = claimTask($taskId, gethostname());
    if (!$claimed) {
        continue;
    }

    // runFFmpeg 内部用 proc_open 执行 3.2 的转码命令,
    // FFmpeg 日志重定向到 /data/logs/transcode/{task_id}.log
    $exitCode = runFFmpeg($task['origin_path'], $task['task_id']);
    finishTask($taskId, $exitCode === 0 ? 'ready' : 'failed');
}

function claimTask(int $id, string $workerId): bool
{
    $db = getDb();
    $stmt = $db->prepare(
        "UPDATE transcode_tasks
         SET status='processing', worker_id=?, started_at=NOW()
         WHERE id=? AND status='pending'"
    );
    $stmt->execute([$workerId, $id]);
    return $stmt->rowCount() === 1;
}

brPop 的第二个参数是超时秒数,5 秒空转后回到循环重新阻塞,这样还能周期性地检查超时任务。 claimTask 用 UPDATE ... WHERE status='pending' 做原子抢占,两个 worker 同时消费到同一个任务时只有一个能抢到,另一个 rowCount() 为 0 直接跳过,避免双进程转同一个视频。任务状态机是 pending → processing → ready/failed,超时未完成的任务由单独一个定时脚本把 processing 回置为 pending,worker 崩溃后任务还能被重新消费。

3.2 双码率转码命令要点:低码率先转,高码率后补

双码率转码我建议拆成两条独立的 FFmpeg 命令按顺序执行,而不是一次性输出两路。拆开的好处是能精准控制低码率优先完成,也方便出错时单独重跑某一档。两条命令参数高度一致,差异只在分辨率与码率:

# 低码率先行:720p,目标码率 1800k
ffmpeg -y -i /data/origin/abc.mp4 \
  -map 0:v:0 -map 0:a:0? \
  -vf "scale=-2:720" \
  -c:v libx264 -profile:v main -preset veryfast -crf 23 \
  -maxrate 1800k -bufsize 3600k \
  -c:a aac -b:a 128k -ac 2 -ar 48000 \
  -force_key_frames "expr:gte(t,n_forced*4)" -sc_threshold 0 \
  -f hls -hls_time 4 -hls_playlist_type vod -hls_list_size 0 \
  -hls_segment_filename /data/vod/{task_id}/low/seg_%05d.ts \
  /data/vod/{task_id}/low/index.m3u8

# 高码率后补:1080p,目标码率 5000k
ffmpeg -y -i /data/origin/abc.mp4 \
  -map 0:v:0 -map 0:a:0? \
  -vf "scale=-2:1080" \
  -c:v libx264 -profile:v main -preset veryfast -crf 20 \
  -maxrate 5000k -bufsize 10000k \
  -c:a aac -b:a 192k -ac 2 -ar 48000 \
  -force_key_frames "expr:gte(t,n_forced*4)" -sc_threshold 0 \
  -f hls -hls_time 4 -hls_playlist_type vod -hls_list_size 0 \
  -hls_segment_filename /data/vod/{task_id}/high/seg_%05d.ts \
  /data/vod/{task_id}/high/index.m3u8

-vf "scale=-2:720" 里的 -2 表示宽度按高度等比取偶数,避免奇数宽度在 yuv420p 下报错。CRF 23 与 20 控制质量基准,叠加 -maxrate 和 -bufsize 限制瞬时码率,避免峰值码率远超 BANDWIDTH 声明值。 -force_key_frames "expr:gte(t,n_forced*4)" 每 4 秒放一个关键帧,与 hls_time 4 对齐; -sc_threshold 0 禁止场景检测自动插关键帧,否则场景切换多出来的 I 帧会打乱切片边界。 -hls_list_size 0 让媒体播放列表保留全部切片。低码率先转的理由很直接:720p 编码耗时一般是 1080p 的一半,先出低码率片源,预览等待时间最短。

3.3 同步任务状态与回调:转码完成怎么通知上层

服务端转码和本地 exec 最大的区别是异步,上层系统不知道 FFmpeg 什么时候结束,需要 PHP worker 在退出码判断后主动回写。任务表里除了 status,我一般还会留 playable 字段,表示秒切预览可用,低码率前 3 个切片已经生成。这样后台列表排序、播放器跳转验证都用这个字段,不需要每次去文件系统数 ts 个数。

一个稳定的终态回写模板:

function finishTask(int $taskId, string $status, string $err = ''): void
{
    $db = getDb();
    $stmt = $db->prepare(
        "UPDATE transcode_tasks
         SET status=?, error_msg=?, finished_at=NOW()
         WHERE id=?"
    );
    $stmt->execute([$status, $err, $taskId]);

    if ($status === 'ready') {
        // 回调业务侧接口,由业务系统更新媒体库状态
        $task = getTask($taskId);
        $callbackUrl = $task['callback_url'] ?? '';
        if ($callbackUrl) {
            file_get_contents($callbackUrl, false, stream_context_create([
                'http' => [
                    'method' => 'POST',
                    'header' => "Content-Type: application/json\r\n",
                    'content' => json_encode(['task_id' => $taskId, 'status' => 'ready']),
                    'timeout' => 5,
                ],
            ]));
        }
    }
}

file_get_contents 做回调是轻量做法,回调链复杂就换 Guzzle 异步或丢回 Redis 由另一个 worker 消费。关键点是回调失败不能影响主状态,所以 timeout 设短,worker 里对异常要做 try/catch。任务表不要存完整 FFmpeg 日志,只存错误摘要;需要排查时再读 /data/logs/transcode/{task_id}.log 。

4. 秒切的关键路径:首切片优先产出与动态 m3u8 索引

4.1 第一个 ts 何时落地:关键帧间隔决定等待时间

用户点击转码到播放器能拉到第一个 ts,中间经历任务排队与首片生成。排队可以压到秒级,真正的瓶颈在首片生成。FFmpeg 的 HLS muxer 要写满一个 hls_time 才会封第一个 ts,所以首片产出时间约等于切片时长加编码预热。短视频业务把 hls_time 调到 2 秒够用,但 2 秒切片会带来两倍请求数,CDN 压力大。折中方案是保持 4 秒切片,再做一个小任务的切片预热:先抽取源文件前 5 秒转成 1 秒时长的 preview.ts,生成 preview/index.m3u8 。用户预览走这个短列表,转码完成后再切到正式 HLS 地址。预览地址与正式地址分开,比把整套切片都缩成 2 秒划算得多。

4.2 低码率先转、阈值放行:切片数够了就对外可播

双码率转码里秒切的顺序策略是低码率先转、高码率后转,且不必等全部切片完成才给播放地址。worker 每生成一个切片后检查目录下 ts 数量,低码率切片到达 3 个时就把任务状态置为 playable ,同时生成一份只包含已完成切片的临时 m3u8。后续切片继续生成,播放器反复拉取同一个 index.m3u8 会发现列表变长。这里要特别注意:点播模式下 #EXT-X-ENDLIST 只在全部切片完成后才出现,转码未完成时列表里不能写这一行,否则播放器会认为列表已经固定,不再重新加载。

4.3 切片列表的稳定写出:避免播放器读到半行 m3u8

FFmpeg 边转码边重写 index.m3u8 时,如果 web 服务器恰好把列表读到一半,播放器会因解析失败直接放弃。这个问题在 Network 面板上表现为 m3u8 请求 200,但随后没有任何 ts 请求,最容易误判成跨域。解决办法是让 FFmpeg 每次写临时文件再原子替换:新版 FFmpeg 默认开启 -hls_flags=temp_file ,转码期间对外暴露的永远是完整刷新的列表。旧版本可以在 PHP 层做防护:FFmpeg 把列表写到 index.m3u8.tmp ,worker 在切片数变化后执行 rename() 到 index.m3u8 。rename 在同一分区内是原子操作,不会出现半个文件。播放列表每出一个切片更新一次,这个频率对静态文件服务压力很小。

4.4 Vue 播放 m3u8 时的起步参数:先拉低码率再自动升级

前端在 Vue 项目里播 m3u8,常见做法是 hls.js。要让双码率真正体现价值,不能默认从最高码率起播。hls.js 默认 startLevel 按带宽估算,首帧往往在 1 秒内就用较高码率去试,弱网下立刻降级,反而出现先卡后清晰。服务端已经知道哪条流码率低,前端可以显式起步:

import Hls from 'hls.js';

const video = document.getElementById('player');
if (Hls.isSupported()) {
  const hls = new Hls({
    startLevel: -1,             // -1 表示从最低码率起步
    capLevelToPlayerSize: true, // 播放器尺寸小于 1080p 时不加载 1080p 流
    manifestLoadingMaxRetry: 3,
  });
  hls.loadSource('/vod/123/master.m3u8');
  hls.attachMedia(video);
}

startLevel: -1 让 hls.js 直接选索引里 BANDWIDTH 最小的一条流,网络稳定后再自动升级。 capLevelToPlayerSize 对移动端特别有用,容器尺寸不够时直接不拉 1080p,省带宽也省了转码压力。iOS Safari 用原生 video 播 m3u8 不经过 hls.js,系统会按当前网络自动选路,服务端只需保证 master 列表正确。

4.5 Nginx 的播放路径配置与跨域响应

播放路径建议和上传路径隔离。转码切片放在 /data/vod/{task_id}/ ,Nginx 提供 /vod/ 映射。hls.js 通过 fetch 拉索引和 ts,跨域时每个响应都要带 Access-Control-Allow-Origin ;而 index.m3u8 在转码期间会被反复改写,不能开强缓存,否则播放器拿到的列表一直停留在第一次请求的版本:

location /vod/ {
    alias /data/vod/;
    add_header Access-Control-Allow-Origin *;
    add_header Cache-Control no-cache;

    types {
        application/vnd.apple.mpegurl m3u8;
        video/mp2t ts;
    }
}

这里用 alias 而不是 root,避免 URL 里多拼一层 vod/ 目录。types 块保证 .m3u8 返回正确的 MIME 类型,否则某些播放器会把列表当纯文本拒绝解析。调试时先 curl 看响应头,再在浏览器 Network 面板对照,判断顺序是 MIME、跨域、列表内容。

5. 秒切验证命令与 m3u8 播放故障的快查方法

5.1 用 ffprobe 验证双码率切片的关键帧与码率

转码完成后不要只凭播放器能放就认为成功。我一般在服务器上做三道验证:先看 master 里两个子列表能被 ffprobe 正常解析,再抽查低码率首片的关键帧间隔,最后对比两路平均码率是否符合预期。

# 验证 m3u8 索引可解析且总时长合理
ffprobe -v error -show_entries format=duration,format_name \
  -of json /data/vod/123/master.m3u8

# 抽查第一个 ts 的流信息
ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,width,height,avg_frame_rate \
  -of json /data/vod/123/low/seg_00000.ts

# 查看切片里的关键帧分布,确认 GOP 没有超过 4 秒
ffprobe -v error -select_streams v -show_entries packet=pts_time,flags \
  -of csv=p=0 /data/vod/123/low/seg_00000.ts | grep K

第一、二条命令用 -v error 过滤日志, -of json 方便在脚本里 json_decode 后断言。第三条把每个 packet 的 pts_time 和标志位输出成 CSV,grep K 只留关键帧,相邻两行的时间差就是实际 GOP 大小。如果差值大于 4 秒,说明 force_key_frames 没有按预期生效,要检查源视频是否带 B 帧乱序或滤镜链是否被跳过。这三条可以封装成 verify_transcode.sh,接入任务回调做自动复测。

5.2 Network 面板没有 m3u8 和转换失败的三个高频原因

播放器黑屏、Network 面板里看不到任何 m3u8 请求时,先别怀疑播放器代码,按对服务端要求最低的顺序查三件事。第一,执行 curl -I http://域名/vod/123/low/index.m3u8 看 HTTP 状态码,404 通常是 alias 路径配错或 task_id 没拼接;同时检查响应头 Content-Type,不是 application/vnd.apple.mpegurl 就按 4.5 的 types 块补。第二,确认响应头带 Access-Control-Allow-Origin 。hls.js 的 fetch 遇到跨域错误时,Network 面板里这条请求状态往往显示 failed,更像证书错误,而原生播放器报错信息很模糊,所以优先看响应头。第三,ts 请求正常但画面卡在首帧,检查 FFmpeg 日志里有没有 non-monotonic timestamp ,输入参数位置加 -fflags +genpts 重新生成连续 PTS;双码率两条命令都要加。把这三条检查写进 verify_transcode.sh,配合 cron 或任务回调自动复测,比等用户反馈黑屏再排查有效得多。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐