当前位置:首页>php>PHP高性能Webman框架FFmpeg实现实时视频流HLS

PHP高性能Webman框架FFmpeg实现实时视频流HLS

  • 2026-09-10 21:03:12
PHP高性能Webman框架FFmpeg实现实时视频流HLS

简介

本文将基于 PHP Webman 高性能框架构建支持 HLS(HTTP 实时流媒体) 的视频流媒体服务器基础架构。我们将通过 FFmpeg 将上传的视频进行转码以及分段处理(将一个视频根据配置的秒数分成多段视频),实现真正的按需加载、实时播放。

什么是 HLS?

HLS(HTTP Live Streaming)是苹果公司提出的基于 HTTP 的流媒体网络传输协议。它的工作原理是将整个流分成一系列小的基于 HTTP 的文件来下载,每次只下载当前播放需要的分片。核心组成:

  • .m3u8 播放列表文件:索引文件,记录了所有分片的信息和顺序
  • .ts 分片文件:实际的视频数据片段,每个片段几秒钟

什么是 FFmpeg?

FFmpeg 是全球领先的多媒体框架工具,具备解码、编码、转码、复用(封装)、解复用(解封装)、流传输、滤镜处理以及播放几乎所有多媒体内容的能力。它支持从最冷门的老旧格式到最前沿的技术标准,具有极强的可移植性,能够在 Linux、macOS、Windows 等各类操作系统上编译运行。

实战案例

安装 FFmpeg

从下面地址下载 FFmpeg(根据你的平台选择,本文以 Linux 为例):

# Ubuntu/Debiansudo apt updatesudo apt install ffmpeg# CentOS/RHELsudo yum install epel-releasesudo yum install ffmpeg# macOSbrew install ffmpeg# Windows - 下载地址# https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip

安装完成后,验证安装:

ffmpeg -version

创建 Webman 项目

composer create-project workerman/webman video-streamingcd video-streaming

项目结构:

video-streaming/├── app/│   ├── controller/│   │   └── VideoController.php│   └── view/│       └── player.html├── config/│   └── route.php├── public/└── runtime/    └── videos/          # 视频存储目录        └── hls/         # HLS转码输出目录

FFmpeg HLS 转码命令

我们将使用以下命令将视频转为 HLS 格式:

ffmpeg -i input.mp4 \  -profile:v baseline \  -level 3.0 \  -start_number 0 \  -hls_time 6 \  -hls_list_size 0 \  -f hls \  ./index.m3u8

参数说明:

参数
说明
-i
输入文件路径,指定要转码的输入视频文件
-profile:v baseline
视频编码配置,使用 H.264 的 Baseline Profile,适合移动设备和低延迟场景(不包含 B 帧,兼容性更好)
-level 3.0
指定 H.264 编码级别为 3.0,限制分辨率、帧率等参数(例如最大支持 720p)
-start_number 0
生成的 HLS 分段文件(.ts)从 0.ts 开始编号
-hls_time 6
每个分段(.ts 文件)的时长为 6 秒
-hls_list_size 0
在 .m3u8 播放列表中保留所有分段(0 表示不轮替,适合点播;直播通常设为 5 或 7)
-f hls
强制输出格式为 HLS
./index.m3u8
输出文件名
执行后生成的文件结构:
output/├── index.m3u8├── index0.ts├── index1.ts├── index2.ts└── ...

生成的 index.m3u8 文件内容示例:

#EXTM3U#EXT-X-VERSION:3#EXT-X-TARGETDURATION:9#EXT-X-MEDIA-SEQUENCE:0#EXTINF:8.808800,index0.ts#EXTINF:5.605600,index1.ts#EXTINF:5.605600,index2.ts#EXTINF:6.306300,index3.ts#EXTINF:3.703700,index4.ts#EXT-X-ENDLIST

2.2 上传视频并转码

配置路由

编辑 config/route.php:

<?phpuseWebman\Route;// 视频上传接口Route::post('/videos/upload', [app\controller\VideoController::class, 'upload']);// 视频列表接口Route::get('/videos/list', [app\controller\VideoController::class, 'list']);// 视频删除接口Route::delete('/videos/delete/{id}', [app\controller\VideoController::class, 'delete']);// 播放器页面Route::get('/player', [app\controller\VideoController::class, 'player']);// 关闭默认路由Route::disableDefaultRoute();

控制器实现

创建 app/controller/VideoController.php:

<?phpnamespaceapp\controller;usesupport\Request;usesupport\Response;classVideoController{// 视频存储根目录private string $storagePath = runtime_path() . 'videos';// HLS输出目录private string $hlsOutputPath = runtime_path() . 'videos/hls';// FFmpeg可执行文件路径(根据实际安装路径修改)private string $ffmpegPath = 'ffmpeg';// 允许的视频格式privatearray $allowedExtensions = ['mp4', 'avi', 'mov', 'mkv', 'flv', 'wmv'];publicfunction__construct(){// 确保目录存在if (!is_dir($this->storagePath)) {            mkdir($this->storagePath, 0755, true);        }if (!is_dir($this->hlsOutputPath)) {            mkdir($this->hlsOutputPath, 0755, true);        }    }/**     * 上传视频并转码为HLS     */publicfunctionupload(Request $request): Response{        $file = $request->file('file');if (!$file || !$file->isValid()) {return json(['code' => 400, 'msg' => '请上传有效的视频文件']);        }// 验证文件扩展名        $extension = strtolower($file->getUploadExtension());if (!in_array($extension, $this->allowedExtensions)) {return json(['code' => 400, 'msg' => '不支持的视频格式,允许的格式:' . implode(', ', $this->allowedExtensions)            ]);        }// 验证文件大小(限制500MB)        $maxSize = 500 * 1024 * 1024;if ($file->getSize() > $maxSize) {return json(['code' => 400, 'msg' => '文件大小不能超过500MB']);        }// 生成唯一ID作为视频标识        $videoId = uniqid('vid_', true);// 保存原始文件        $inputFileName = $videoId . '.' . $extension;        $inputFilePath = $this->storagePath . '/' . $inputFileName;        $file->move($inputFilePath);// 创建HLS输出目录        $outputDir = $this->hlsOutputPath . '/' . $videoId;if (!is_dir($outputDir)) {            mkdir($outputDir, 0755, true);        }// 构建FFmpeg转码命令        $outputM3u8 = $outputDir . '/index.m3u8';        $ffmpegCmd = sprintf('%s -i %s -profile:v baseline -level 3.0 -start_number 0 -hls_time 6 -hls_list_size 0 -f hls %s',$this->ffmpegPath,            escapeshellarg($inputFilePath),            escapeshellarg($outputM3u8)        );// 执行转码命令        $output = [];        $returnVar = 0;        exec($ffmpegCmd . ' 2>&1', $output, $returnVar);if ($returnVar !== 0) {// 转码失败,清理文件            @unlink($inputFilePath);$this->deleteDirectory($outputDir);return json(['code' => 500, 'msg' => '视频转码失败','error' => implode("\n", $output)            ]);        }// 生成缩略图        $thumbnailPath = $outputDir . '/thumbnail.jpg';$this->generateThumbnail($inputFilePath, $thumbnailPath);// 保存视频信息到数据库(这里用JSON文件模拟)$this->saveVideoInfo($videoId, ['id' => $videoId,'original_name' => $file->getUploadName(),'file_size' => $file->getSize(),'format' => $extension,'upload_time' => date('Y-m-d H:i:s'),'hls_path' => '/streaming/hls/' . $videoId . '/index.m3u8','thumbnail' => '/streaming/hls/' . $videoId . '/thumbnail.jpg',        ]);return json(['code' => 200, 'msg' => '上传成功并转码为HLS格式','data' => ['video_id' => $videoId,'hls_url' => '/streaming/hls/' . $videoId . '/index.m3u8','thumbnail' => '/streaming/hls/' . $videoId . '/thumbnail.jpg',            ]        ]);    }/**     * 获取视频列表     */publicfunctionlist(Request $request): Response{        $videoInfoPath = $this->storagePath . '/video_info.json';        $videos = [];if (file_exists($videoInfoPath)) {            $videos = json_decode(file_get_contents($videoInfoPath), true) ?: [];        }return json(['code' => 200, 'data' => array_values($videos)]);    }/**     * 删除视频     */publicfunctiondelete(Request $request, $id): Response{        $videoInfoPath = $this->storagePath . '/video_info.json';        $videos = [];if (file_exists($videoInfoPath)) {            $videos = json_decode(file_get_contents($videoInfoPath), true) ?: [];        }if (!isset($videos[$id])) {return json(['code' => 404, 'msg' => '视频不存在']);        }        $videoInfo = $videos[$id];// 删除原始文件        $originalFile = $this->storagePath . '/' . $id . '.' . $videoInfo['format'];if (file_exists($originalFile)) {            @unlink($originalFile);        }// 删除HLS输出目录        $hlsDir = $this->hlsOutputPath . '/' . $id;if (is_dir($hlsDir)) {$this->deleteDirectory($hlsDir);        }// 从列表中移除unset($videos[$id]);        file_put_contents($videoInfoPath, json_encode($videos, JSON_PRETTY_PRINT));return json(['code' => 200, 'msg' => '删除成功']);    }/**     * 播放器页面     */publicfunctionplayer(Request $request): Response{return view('player');    }/**     * 生成缩略图     */privatefunctiongenerateThumbnail(string $inputFile, string $outputFile): bool{// 从第5秒截取一帧作为缩略图        $cmd = sprintf('%s -i %s -ss 00:00:05 -vframes 1 -q:v 2 %s',$this->ffmpegPath,            escapeshellarg($inputFile),            escapeshellarg($outputFile)        );        exec($cmd . ' 2>&1', $output, $returnVar);return $returnVar === 0;    }/**     * 保存视频信息     */privatefunctionsaveVideoInfo(string $videoId, array $info): void{        $videoInfoPath = $this->storagePath . '/video_info.json';        $videos = [];if (file_exists($videoInfoPath)) {            $videos = json_decode(file_get_contents($videoInfoPath), true) ?: [];        }        $videos[$videoId] = $info;        file_put_contents($videoInfoPath, json_encode($videos, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));    }/**     * 递归删除目录     */privatefunctiondeleteDirectory(string $dir): bool{if (!is_dir($dir)) {returnfalse;        }        $items = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),            \RecursiveIteratorIterator::CHILD_FIRST        );foreach ($items as $item) {if ($item->isDir()) {                rmdir($item->getRealPath());            } else {                unlink($item->getRealPath());            }        }return rmdir($dir);    }}

配置静态资源访问

为了让前端能访问转码后的 .m3u8 和 .ts 文件,需要配置静态资源路径。 编辑 config/static.php(如果没有则创建):

<?phpreturn [// 静态文件路由映射'map' => ['/streaming' => runtime_path() . 'videos',    ],];

或者通过中间件方式更灵活地控制访问,创建 app/middleware/StreamingAccess.php:

<?phpnamespaceapp\middleware;useWebman\MiddlewareInterface;useWebman\Http\Response;useWebman\Http\Request;classStreamingAccessimplementsMiddlewareInterface{publicfunctionprocess(Request $request, callable $handler): Response{        $path = $request->path();// 只允许访问hls目录下的文件if (strpos($path, '/streaming/hls/') === 0) {            $filePath = runtime_path() . 'videos' . substr($path, strlen('/streaming'));if (file_exists($filePath) && is_file($filePath)) {                $extension = pathinfo($filePath, PATHINFO_EXTENSION);                $contentTypes = ['m3u8' => 'application/vnd.apple.mpegurl','ts' => 'video/mp2t','jpg' => 'image/jpeg','png' => 'image/png',                ];                $contentType = $contentTypes[$extension] ?? 'application/octet-stream';returnnew Response(200, ['Content-Type' => $contentType,'Cache-Control' => 'public, max-age=3600','Access-Control-Allow-Origin' => '*',                ], file_get_contents($filePath));            }        }return $handler($request);    }}

在 config/middleware.php 中注册中间件:

<?phpreturn ['' => [        app\middleware\StreamingAccess::class,    ],];

2.3 播放转码后的视频

下载 hls.js

从 CDN 下载 hls.js 播放器库:

https://cdn.jsdelivr.net/npm/hls.js@latest

将文件保存到 public/hls.js。

创建播放器页面

创建 app/view/player.html:

<!DOCTYPE html><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>实时视频流技术 - Webman + FFmpeg HLS</title><scriptsrc="/hls.js"></script><style>        * {margin: 0;padding: 0;box-sizing: border-box;        }body {font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;background: #0f0f0f;color: #fff;min-height: 100vh;        }.container {max-width: 1200px;margin: 0 auto;padding: 20px;        }header {text-align: center;padding: 30px0;border-bottom: 1px solid #333;margin-bottom: 30px;        }headerh1 {font-size: 32px;margin-bottom: 10px;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);-webkit-background-clip: text;-webkit-text-fill-color: transparent;        }headerp {color: #888;font-size: 16px;        }.upload-section {background: #1a1a1a;border-radius: 12px;padding: 30px;margin-bottom: 30px;border: 2px dashed #333;transition: border-color 0.3s;        }.upload-section:hover {border-color: #667eea;        }.upload-sectionh2 {margin-bottom: 20px;font-size: 22px;        }.upload-form {display: flex;gap: 15px;align-items: center;flex-wrap: wrap;        }.file-input-wrapper {position: relative;flex: 1;min-width: 200px;        }.file-input-wrapperinput[type="file"] {width: 100%;padding: 12px;background: #252525;border: 1px solid #444;border-radius: 8px;color: #fff;cursor: pointer;        }.upload-btn {padding: 12px30px;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);border: none;border-radius: 8px;color: #fff;font-size: 16px;font-weight: 600;cursor: pointer;transition: opacity 0.3s;        }.upload-btn:hover {opacity: 0.9;        }.upload-btn:disabled {opacity: 0.5;cursor: not-allowed;        }.progress-bar {width: 100%;height: 4px;background: #252525;border-radius: 2px;margin-top: 15px;display: none;        }.progress-bar.progress {height: 100%;background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);border-radius: 2px;width: 0%;transition: width 0.3s;        }.video-grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));gap: 20px;margin-bottom: 30px;        }.video-card {background: #1a1a1a;border-radius: 12px;overflow: hidden;cursor: pointer;transition: transform 0.3s, box-shadow 0.3s;        }.video-card:hover {transform: translateY(-5px);box-shadow: 010px30pxrgba(102, 126, 234, 0.2);        }.video-thumbnail {width: 100%;height: 180px;object-fit: cover;background: #252525;        }.video-info {padding: 15px;        }.video-infoh3 {font-size: 16px;margin-bottom: 8px;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;        }.video-meta {font-size: 13px;color: #888;display: flex;justify-content: space-between;        }.player-section {background: #1a1a1a;border-radius: 12px;overflow: hidden;display: none;        }.player-header {display: flex;justify-content: space-between;align-items: center;padding: 15px20px;background: #141414;border-bottom: 1px solid #333;        }.player-headerh2 {font-size: 18px;        }.close-btn {background: none;border: none;color: #888;font-size: 24px;cursor: pointer;transition: color 0.3s;        }.close-btn:hover {color: #fff;        }video {width: 100%;max-height: 70vh;display: block;background: #000;        }.video-stats {padding: 15px20px;display: flex;gap: 20px;font-size: 13px;color: #888;        }.empty-state {text-align: center;padding: 60px20px;color: #666;        }.empty-statesvg {width: 80px;height: 80px;margin-bottom: 20px;opacity: 0.3;        }.notification {position: fixed;top: 20px;right: 20px;padding: 15px25px;border-radius: 8px;color: #fff;font-weight: 500;z-index: 1000;transform: translateX(120%);transition: transform 0.3s;        }.notification.show {transform: translateX(0);        }.notification.success {background: #10b981;        }.notification.error {background: #ef4444;        }.notification.info {background: #3b82f6;        }@media (max-width:768px) {.container {padding: 15px;            }headerh1 {font-size: 24px;            }.upload-form {flex-direction: column;            }.video-grid {grid-template-columns: 1fr;            }        }</style></head><body><divclass="container"><header><h1>Webman + FFmpeg 实时视频流</h1><p>基于HLS协议的视频流媒体服务器</p></header><sectionclass="upload-section"><h2>📤 上传视频</h2><divclass="upload-form"><divclass="file-input-wrapper"><inputtype="file"id="videoFile"accept="video/*"></div><buttonclass="upload-btn"id="uploadBtn"disabled>上传并转码</button></div><divclass="progress-bar"id="progressBar"><divclass="progress"id="progress"></div></div></section><sectionid="videoList"><divclass="empty-state"id="emptyState"><svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"stroke="currentColor"><pathstroke-linecap="round"stroke-linejoin="round"stroke-width="1"d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg><p>暂无视频,请上传视频文件</p></div><divclass="video-grid"id="videoGrid"></div></section><sectionclass="player-section"id="playerSection"><divclass="player-header"><h2id="playerTitle">正在播放</h2><buttonclass="close-btn"id="closePlayer">&times;</button></div><videoid="videoPlayer"controlsautoplay></video><divclass="video-stats"><spanid="videoResolution">分辨率: --</span><spanid="videoDuration">时长: --</span><spanid="videoSegments">分片数: --</span></div></section></div><divclass="notification"id="notification"></div><script>// API基础URLconst API_BASE = '';// DOM元素const videoFile = document.getElementById('videoFile');const uploadBtn = document.getElementById('uploadBtn');const progressBar = document.getElementById('progressBar');const progress = document.getElementById('progress');const videoGrid = document.getElementById('videoGrid');const emptyState = document.getElementById('emptyState');const playerSection = document.getElementById('playerSection');const videoPlayer = document.getElementById('videoPlayer');const playerTitle = document.getElementById('playerTitle');const closePlayer = document.getElementById('closePlayer');const notification = document.getElementById('notification');let hls = null;let currentVideoInfo = null;// 显示通知functionshowNotification(message, type = 'info') {            notification.textContent = message;            notification.className = 'notification ' + type + ' show';            setTimeout(() => {                notification.classList.remove('show');            }, 3000);        }// 文件选择事件        videoFile.addEventListener('change', function() {            uploadBtn.disabled = !this.files.length;        });// 上传视频        uploadBtn.addEventListener('click', asyncfunction() {const file = videoFile.files[0];if (!file) return;const formData = new FormData();            formData.append('file', file);            uploadBtn.disabled = true;            uploadBtn.textContent = '上传中...';            progressBar.style.display = 'block';try {const xhr = new XMLHttpRequest();                xhr.upload.addEventListener('progress', (e) => {if (e.lengthComputable) {const percentComplete = (e.loaded / e.total) * 100;                        progress.style.width = percentComplete + '%';                    }                });                xhr.addEventListener('load', function() {if (xhr.status === 200) {const response = JSON.parse(xhr.responseText);if (response.code === 200) {                            showNotification('上传成功,正在转码...', 'success');// 转码需要时间,延迟刷新列表                            setTimeout(loadVideoList, 3000);                        } else {                            showNotification(response.msg || '上传失败', 'error');                        }                    } else {                        showNotification('上传失败', 'error');                    }                    resetUploadForm();                });                xhr.addEventListener('error', function() {                    showNotification('网络错误', 'error');                    resetUploadForm();                });                xhr.open('POST', API_BASE + '/videos/upload');                xhr.send(formData);            } catch (error) {                showNotification('上传异常: ' + error.message, 'error');                resetUploadForm();            }        });// 重置上传表单functionresetUploadForm() {            uploadBtn.disabled = false;            uploadBtn.textContent = '上传并转码';            progressBar.style.display = 'none';            progress.style.width = '0%';            videoFile.value = '';        }// 加载视频列表asyncfunctionloadVideoList() {try {const response = await fetch(API_BASE + '/videos/list');const result = await response.json();if (result.code === 200 && result.data.length > 0) {                    emptyState.style.display = 'none';                    renderVideoGrid(result.data);                } else {                    emptyState.style.display = 'block';                    videoGrid.innerHTML = '';                }            } catch (error) {console.error('加载视频列表失败:', error);            }        }// 渲染视频网格functionrenderVideoGrid(videos) {            videoGrid.innerHTML = videos.map(video =>`                <div class="video-card" onclick="playVideo('${video.id}', '${video.original_name}')">                    <img class="video-thumbnail"                          src="${video.thumbnail}"                          alt="${video.original_name}"                         onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 300 180%22%3E%3Crect fill=%22%23252525%22 width=%22300%22 height=%22180%22/%3E%3Ctext fill=%22%23666%22 font-size=%2220%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3E无缩略图%3C/text%3E%3C/svg%3E'">                    <div class="video-info">                        <h3 title="${video.original_name}">${video.original_name}</h3>                        <div class="video-meta">                            <span>${formatFileSize(video.file_size)}</span>                            <span>${video.upload_time}</span>                        </div>                    </div>                </div>            `).join('');        }// 播放视频functionplayVideo(videoId, title) {const url = API_BASE + '/streaming/hls/' + videoId + '/index.m3u8';            playerTitle.textContent = '正在播放: ' + title;            playerSection.style.display = 'block';// 销毁之前的HLS实例if (hls) {                hls.destroy();            }if (Hls.isSupported()) {                hls = new Hls({maxBufferLength: 30,maxMaxBufferLength: 60,                });                hls.loadSource(url);                hls.attachMedia(videoPlayer);                hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {                    videoPlayer.play();                    updateVideoStats(data);                });                hls.on(Hls.Events.ERROR, function(event, data) {if (data.fatal) {switch (data.type) {case Hls.ErrorTypes.NETWORK_ERROR:console.error('网络错误,尝试恢复...');                                hls.startLoad();break;case Hls.ErrorTypes.MEDIA_ERROR:console.error('媒体错误,尝试恢复...');                                hls.recoverMediaError();break;default:console.error('无法恢复的错误');                                hls.destroy();                                showNotification('播放失败', 'error');break;                        }                    }                });            } elseif (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) {// Safari原生支持HLS                videoPlayer.src = url;                videoPlayer.addEventListener('loadedmetadata', function() {                    videoPlayer.play();                });            } else {                showNotification('您的浏览器不支持HLS播放', 'error');            }// 滚动到播放器位置            playerSection.scrollIntoView({ behavior: 'smooth' });        }// 更新视频统计信息functionupdateVideoStats(data) {// 这些信息可以从API获取,这里简化处理document.getElementById('videoSegments').textContent = '分片数: ' + (data.levels && data.levels[0] ? data.levels[0].details.fragments.length : '--');        }// 关闭播放器        closePlayer.addEventListener('click', function() {            playerSection.style.display = 'none';if (hls) {                hls.destroy();                hls = null;            }            videoPlayer.pause();            videoPlayer.src = '';        });// 格式化文件大小functionformatFileSize(bytes) {if (bytes === 0) return'0 B';const k = 1024;const sizes = ['B', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));returnparseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];        }// 初始化加载视频列表        loadVideoList();</script></body></html>

2.4 测试运行

启动 Webman 服务

cd video-streamingphp windows.php  # Windows# 或php start.php start  # Linux

默认监听 http://0.0.0.0:8787

测试上传接口

使用 Postman 或 curl 测试:

curl -X POST http://localhost:8787/videos/upload \  -F "file=@/path/to/your/video.mp4"

响应示例:

{"code": 200,"msg": "上传成功并转码为HLS格式","data": {"video_id": "vid_648f5a2b3c1d4","hls_url": "/streaming/hls/vid_648f5a2b3c1d4/index.m3u8","thumbnail": "/streaming/hls/vid_648f5a2b3c1d4/thumbnail.jpg"    }}

访问播放器

打开浏览器访问:http://localhost:8787/player

3. 进阶功能

3.1 生成缩略图

在视频列表中展示缩略图是常见需求,FFmpeg 可以轻松实现:

ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg

参数说明:

参数
说明
-i
输入视频文件
-ss 00:00:05
从第5秒开始提取
-vframes 1
只提取1帧
-q:v 2
控制输出质量(1-31,值越小质量越高,2通常足够)
thumbnail.jpg
输出缩略图文件名(支持.jpg, .png, .webp等)

3.2 多分辨率自适应

为不同网络条件提供多分辨率版本,修改转码命令:

// 在 VideoController.php 的 upload 方法中,替换转码命令为多分辨率版本$ffmpegCmd = sprintf('%s -i %s ' .'-map 0:v:0 -map 0:a:0 -map 0:v:0 -map 0:a:0 -map 0:v:0 -map 0:a:0 ' .'-c:v libx264 -c:a aac ' .'-filter:v:0 "scale=1920:1080" -b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k ' .'-filter:v:1 "scale=1280:720" -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k ' .'-filter:v:2 "scale=854:480" -b:v:2 1400k -maxrate:v:2 1498k -bufsize:v:2 2100k ' .'-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" ' .'-master_pl_name master.m3u8 ' .'-f hls -hls_time 6 -hls_list_size 0 ' .'-hls_segment_filename "%s/%%v/segment_%%03d.ts" ' .'%s/%%v/index.m3u8',$this->ffmpegPath,    escapeshellarg($inputFilePath),    escapeshellarg($outputDir),    escapeshellarg($outputDir));

3.3 异步转码队列

对于大文件,同步转码会导致请求超时。可以使用 Webman 的自定义进程实现异步队列: 创建 app/process/TranscodeQueue.php:

<?phpnamespaceapp\process;useWorkerman\Connection\TcpConnection;useWebman\Config;classTranscodeQueue{privatearray $queue = [];private bool $processing = false;publicfunctiononWorkerStart(): void{// 启动队列处理$this->processQueue();    }publicfunctiononMessage(TcpConnection $connection, $data): void{        $task = json_decode($data, true);if ($task && isset($task['type']) && $task['type'] === 'transcode') {$this->queue[] = $task;            $connection->send(json_encode(['status' => 'queued', 'position' => count($this->queue)]));if (!$this->processing) {$this->processQueue();            }        }    }privatefunctionprocessQueue(): void{if ($this->processing || empty($this->queue)) {return;        }$this->processing = true;        $task = array_shift($this->queue);// 执行转码任务$this->executeTranscode($task);$this->processing = false;// 继续处理下一个任务if (!empty($this->queue)) {$this->processQueue();        }    }privatefunctionexecuteTranscode(array $task): void{        $ffmpegCmd = sprintf('%s -i %s -profile:v baseline -level 3.0 -start_number 0 -hls_time 6 -hls_list_size 0 -f hls %s','ffmpeg',            escapeshellarg($task['input_file']),            escapeshellarg($task['output_file'])        );        exec($ffmpegCmd . ' 2>&1', $output, $returnVar);// 更新任务状态到数据库或缓存// ...    }}

在 config/process.php 中注册:

<?phpreturn ['transcode_queue' => ['handler' => app\process\TranscodeQueue::class,'listen' => 'text://0.0.0.0:8788','count' => 1,  // 单进程处理,避免并发问题    ],];

3.4 视频加密(DRM)

为视频添加 AES-128 加密,保护内容安全:

// 生成加密密钥$encryptionKey = random_bytes(16);$keyPath = $outputDir . '/enc.key';file_put_contents($keyPath, $encryptionKey);// 生成密钥信息文件$keyInfoPath = $outputDir . '/enc.keyinfo';$keyInfoContent = $keyPath . "\n";$keyInfoContent .= $keyPath . "\n";file_put_contents($keyInfoPath, $keyInfoContent);// 修改FFmpeg命令,添加加密参数$ffmpegCmd = sprintf('%s -i %s -profile:v baseline -level 3.0 ' .'-hls_key_info_file %s ' .'-start_number 0 -hls_time 6 -hls_list_size 0 -f hls %s',$this->ffmpegPath,    escapeshellarg($inputFilePath),    escapeshellarg($keyInfoPath),    escapeshellarg($outputM3u8));

3.5 实时直播流

虽然本文主要介绍点播(VOD),但 FFmpeg 也支持实时直播流转 HLS:

// 接收RTMP直播流并转为HLS$ffmpegCmd = sprintf('%s -listen 1 -i rtmp://0.0.0.0:1935/live/stream ' .'-c:v libx264 -preset veryfast -tune zerolatency ' .'-c:a aac -ar 44100 -b:a 128k ' .'-f hls -hls_time 4 -hls_list_size 6 -hls_flags delete_segments ' .'-hls_segment_filename "%s/segment_%%03d.ts" %s',$this->ffmpegPath,    escapeshellarg($outputDir),    escapeshellarg($outputDir . '/live.m3u8'));

4. 完整项目结构

最终的项目文件结构:

video-streaming/├── app/│   ├── controller/│   │   └── VideoController.php      # 视频控制器│   ├── middleware/│   │   └── StreamingAccess.php      # 流媒体访问中间件│   ├── process/│   │   └── TranscodeQueue.php       # 异步转码队列│   └── view/│       └── player.html              # 播放器页面├── config/│   ├── middleware.php               # 中间件配置│   ├── process.php                  # 自定义进程配置│   ├── route.php                    # 路由配置│   └── static.php                   # 静态资源配置├── public/│   └── hls.js                       # HLS播放器库├── runtime/│   └── videos/                      # 视频存储(运行时生成)│       ├── video_info.json          # 视频信息数据库│       └── hls/                     # HLS转码输出├── composer.json└── start.php                        # 启动文件

最新文章

随机文章