
本文将基于 PHP Webman 高性能框架构建支持 HLS(HTTP 实时流媒体) 的视频流媒体服务器基础架构。我们将通过 FFmpeg 将上传的视频进行转码以及分段处理(将一个视频根据配置的秒数分成多段视频),实现真正的按需加载、实时播放。
HLS(HTTP Live Streaming)是苹果公司提出的基于 HTTP 的流媒体网络传输协议。它的工作原理是将整个流分成一系列小的基于 HTTP 的文件来下载,每次只下载当前播放需要的分片。核心组成:
.m3u8 播放列表文件:索引文件,记录了所有分片的信息和顺序.ts 分片文件:实际的视频数据片段,每个片段几秒钟FFmpeg 是全球领先的多媒体框架工具,具备解码、编码、转码、复用(封装)、解复用(解封装)、流传输、滤镜处理以及播放几乎所有多媒体内容的能力。它支持从最冷门的老旧格式到最前沿的技术标准,具有极强的可移植性,能够在 Linux、macOS、Windows 等各类操作系统上编译运行。
从下面地址下载 FFmpeg(根据你的平台选择,本文以 Linux 为例):
# Ubuntu/Debian
sudo apt update
sudo apt install ffmpeg
# CentOS/RHEL
sudo yum install epel-release
sudo yum install ffmpeg
# macOS
brew install ffmpeg
# Windows - 下载地址
# https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
安装完成后,验证安装:
ffmpeg -version
composer create-project workerman/webman video-streaming
cd 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 | 视频编码配置,使用 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
编辑 config/route.php:
<?php
use Webman\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:
<?php
namespaceapp\controller;
usesupport\Request;
usesupport\Response;
class VideoController
{
// 视频存储根目录
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
*/
publicfunction upload(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',
]
]);
}
/**
* 获取视频列表
*/
publicfunction list(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)]);
}
/**
* 删除视频
*/
publicfunction delete(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' => '删除成功']);
}
/**
* 播放器页面
*/
publicfunction player(Request $request): Response
{
return view('player');
}
/**
* 生成缩略图
*/
privatefunction generateThumbnail(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;
}
/**
* 保存视频信息
*/
privatefunction saveVideoInfo(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));
}
/**
* 递归删除目录
*/
privatefunction deleteDirectory(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(如果没有则创建):
<?php
return [
// 静态文件路由映射
'map' => [
'/streaming' => runtime_path() . 'videos',
],
];
或者通过中间件方式更灵活地控制访问,创建 app/middleware/StreamingAccess.php:
<?php
namespaceapp\middleware;
useWebman\MiddlewareInterface;
useWebman\Http\Response;
useWebman\Http\Request;
class StreamingAccess implements MiddlewareInterface
{
publicfunction process(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 中注册中间件:
<?php
return [
'' => [
app\middleware\StreamingAccess::class,
],
];
从 CDN 下载 hls.js 播放器库:
https://cdn.jsdelivr.net/npm/hls.js@latest
将文件保存到 public/hls.js。
创建 app/view/player.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实时视频流技术 - Webman + FFmpeg HLS</title>
<script src="/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>
<div class="container">
<header>
<h1>Webman + FFmpeg 实时视频流</h1>
<p>基于HLS协议的视频流媒体服务器</p>
</header>
<section class="upload-section">
<h2>📤 上传视频</h2>
<div class="upload-form">
<div class="file-input-wrapper">
<input type="file" id="videoFile" accept="video/*">
</div>
<button class="upload-btn" id="uploadBtn" disabled>上传并转码</button>
</div>
<div class="progress-bar" id="progressBar">
<div class="progress" id="progress"></div>
</div>
</section>
<section id="videoList">
<div class="empty-state" id="emptyState">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-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>
<div class="video-grid" id="videoGrid"></div>
</section>
<section class="player-section" id="playerSection">
<div class="player-header">
<h2 id="playerTitle">正在播放</h2>
<button class="close-btn" id="closePlayer">×</button>
</div>
<video id="videoPlayer" controls autoplay></video>
<div class="video-stats">
<span id="videoResolution">分辨率: --</span>
<span id="videoDuration">时长: --</span>
<span id="videoSegments">分片数: --</span>
</div>
</section>
</div>
<div class="notification" id="notification"></div>
<script>
// API基础URL
const 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;
// 显示通知
function showNotification(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();
}
});
// 重置上传表单
function resetUploadForm() {
uploadBtn.disabled = false;
uploadBtn.textContent = '上传并转码';
progressBar.style.display = 'none';
progress.style.width = '0%';
videoFile.value = '';
}
// 加载视频列表
asyncfunction loadVideoList() {
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);
}
}
// 渲染视频网格
function renderVideoGrid(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('');
}
// 播放视频
function playVideo(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' });
}
// 更新视频统计信息
function updateVideoStats(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 = '';
});
// 格式化文件大小
function formatFileSize(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-streaming
php 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 | 从第5秒开始提取 |
-vframes 1 | 只提取1帧 |
-q:v 2 | 控制输出质量(1-31,值越小质量越高,2通常足够) |
thumbnail.jpg | 输出缩略图文件名(支持.jpg, .png, .webp等) |
为不同网络条件提供多分辨率版本,修改转码命令:
// 在 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:
<?php
namespaceapp\process;
useWorkerman\Connection\TcpConnection;
useWebman\Config;
class TranscodeQueue
{
privatearray $queue = [];
private bool $processing = false;
publicfunction onWorkerStart(): void
{
// 启动队列处理
$this->processQueue();
}
publicfunction onMessage(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();
}
}
}
privatefunction processQueue(): 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();
}
}
privatefunction executeTranscode(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 中注册:
<?php
return [
'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 # 启动文件