本文将基于 PHP Webman 高性能框架构建支持 HLS(HTTP 实时流媒体) 的视频流媒体服务器基础架构。我们将通过 FFmpeg 将上传的视频进行转码以及分段处理(将一个视频根据配置的秒数分成多段视频),实现真正的按需加载、实时播放。
HLS(HTTP Live Streaming)是苹果公司提出的基于 HTTP 的流媒体网络传输协议。它的工作原理是将整个流分成一系列小的基于 HTTP 的文件来下载,每次只下载当前播放需要的分片。核心组成:
.m3u8 播放列表文件:索引文件,记录了所有分片的信息和顺序.ts 分片文件:实际的视频数据片段,每个片段几秒钟FFmpeg 是全球领先的多媒体框架工具,具备解码、编码、转码、复用(封装)、解复用(解封装)、流传输、滤镜处理以及播放几乎所有多媒体内容的能力。它支持从最冷门的老旧格式到最前沿的技术标准,具有极强的可移植性,能够在 Linux、macOS、Windows 等各类操作系统上编译运行。
从下面地址下载 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 -versioncomposer 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转码输出目录我们将使用以下命令将视频转为 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 | |
-level 3.0 | |
-start_number 0 | |
-hls_time 6 | |
-hls_list_size 0 | |
-f 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编辑 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, ],];从 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">×</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>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
在视频列表中展示缩略图是常见需求,FFmpeg 可以轻松实现:
ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg参数说明:
-i | |
-ss 00:00:05 | |
-vframes 1 | |
-q:v 2 | |
thumbnail.jpg |
为不同网络条件提供多分辨率版本,修改转码命令:
// 在 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));对于大文件,同步转码会导致请求超时。可以使用 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, // 单进程处理,避免并发问题 ],];为视频添加 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));虽然本文主要介绍点播(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'));最终的项目文件结构:
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 # 启动文件