PHP 实现在线视频播放完整方案:从后端存储到前端适配
一、技术选型
1. 核心技术栈
后端:PHP 7.4+(稳定兼容,支持大文件处理)、ThinkPHP 6.x(可选,快速构建接口)、Nginx(反向代理 + 静态资源处理)
前端:HTML5 Video 原生 API、Video.js(增强播放体验,支持 HLS/DASH)
存储方案:
本地存储(开发 / 测试环境):服务器磁盘直接存储
云存储(生产环境):阿里云 OSS、腾讯云 COS(高可用、CDN 适配)
传输协议:
HTTP 分片传输(基础方案,支持断点续传)
HLS(HTTP Live Streaming,适配移动端 + 弱网环境)
辅助工具:FFmpeg(视频转码,统一格式)、Redis(可选,缓存视频元信息)
2. 关键技术说明
Video.js:开源免费的跨浏览器播放器,支持 MP4、WebM、HLS(.m3u8)等格式,自带进度条、倍速、全屏等控件,可自定义皮肤。
分片传输:通过解析 HTTP Range 请求头,将大视频文件分割为小片段(如 4MB / 片),前端按需加载,避免一次性加载大文件导致的卡顿。
视频转码:用 FFmpeg 将原始视频(AVI、MKV、MOV 等)转为 MP4(兼容性最优)或 HLS 格式(.m3u8 索引 + ts 分片),解决不同设备格式兼容问题。
Nginx 静态资源优化:视频文件本质是静态资源,通过 Nginx 直接处理视频请求,比 PHP 原生输出更高效,降低后端压力。
二、后端实现(PHP + Nginx)
1. 环境准备
安装 PHP 7.4+,开启
fileinfo、curl扩展(处理文件类型识别、HTTP 请求)安装 Nginx(配置静态资源访问 + 反向代理)
(可选)安装 FFmpeg:
yum install ffmpeg(CentOS)或brew install ffmpeg(Mac)
2. 核心功能实现(原生 PHP + 少量框架思想)
(1)视频上传接口(支持大文件分片上传)
<?php/**
* 视频分片上传接口
* 接收参数:file(分片文件)、fileName(原文件名)、chunkIndex(分片索引)、totalChunks(总分片数)
*/header("Content-Type: application/json; charset=utf-8");header("Access-Control-Allow-Origin: *"); // 开发环境允许跨域,生产环境限制具体域名// 配置参数$videoStoragePath = '/www/video-storage/'; // 视频存储根目录$tempShardPath = $videoStoragePath . 'temp/'; // 临时分片目录$maxShardSize = 4 * 1024 * 1024; // 分片大小(4MB)// 确保目录存在if (!is_dir($videoStoragePath)) {
mkdir($videoStoragePath, 0755, true);}if (!is_dir($tempShardPath)) {
mkdir($tempShardPath, 0755, true);}// 接收请求参数$file = $_FILES['file'] ?? null;$fileName = $_POST['fileName'] ?? '';$chunkIndex = $_POST['chunkIndex'] ?? 0;$totalChunks = $_POST['totalChunks'] ?? 1;// 参数校验if (!$file || !$fileName || $chunkIndex < 0 || $totalChunks < 1) {
echo json_encode(['code' => 400, 'msg' => '参数不全']);
exit;}// 生成唯一文件标识(避免文件名重复)$fileHash = md5($fileName . microtime(true));$shardDir = $tempShardPath . $fileHash . '/';// 确保分片目录存在if (!is_dir($shardDir)) {
mkdir($shardDir, 0755, true);}// 保存当前分片$shardFilePath = $shardDir . $chunkIndex;if (!move_uploaded_file($file['tmp_name'], $shardFilePath)) {
echo json_encode(['code' => 500, 'msg' => '分片保存失败']);
exit;}// 检查是否所有分片上传完成,若完成则合并if ($chunkIndex == $totalChunks - 1) {
$finalFileName = $fileHash . '.' . pathinfo($fileName, PATHINFO_EXTENSION);
$finalFilePath = $videoStoragePath . $finalFileName;
// 合并所有分片(按索引顺序)
$fp = fopen($finalFilePath, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$currentShard = $shardDir . $i;
if (!file_exists($currentShard)) {
fclose($fp);
echo json_encode(['code' => 500, 'msg' => '分片缺失,合并失败']);
exit;
}
// 读取分片并写入最终文件
fwrite($fp, file_get_contents($currentShard));
// 删除临时分片
unlink($currentShard);
}
fclose($fp);
// 删除临时分片目录
rmdir($shardDir);
// (可选)调用FFmpeg转码为HLS格式(后台执行,避免阻塞)
$hlsDir = $videoStoragePath . 'hls/' . $fileHash . '/';
if (!is_dir($hlsDir)) {
mkdir($hlsDir, 0755, true);
}
// 后台执行转码命令(&符号让命令在后台运行)
$ffmpegCmd = "ffmpeg -i {$finalFilePath} -hls_time 10 -hls_list_size 0 -f hls {$hlsDir}index.m3u8 > /dev/null 2>&1 &";
exec($ffmpegCmd);
// 返回结果(包含播放地址)
$playUrl = "http://your-domain.com/video/play/{$finalFileName}";
$hlsPlayUrl = "http://your-domain.com/video/play/hls/{$fileHash}/index.m3u8";
echo json_encode([
'code' => 200,
'msg' => '上传完成',
'data' => [
'playUrl' => $playUrl,
'hlsPlayUrl' => $hlsPlayUrl,
'fileName' => $finalFileName
]
]);} else {
echo json_encode(['code' => 200, 'msg' => "分片{$chunkIndex}上传成功"]);}?>(2)视频分片播放接口(支持断点续传)
<?php/**
* 视频流式播放接口
* 支持Range请求,断点续传
*/header("Access-Control-Allow-Origin: *");$videoStoragePath = '/www/video-storage/';$fileName = $_GET['fileName'] ?? '';$hlsPath = $_GET['hlsPath'] ?? '';// 处理普通视频文件(MP4等)if ($fileName) {
$videoPath = $videoStoragePath . $fileName;}// 处理HLS文件(.m3u8或.ts)elseif ($hlsPath) {
$videoPath = $videoStoragePath . 'hls/' . $hlsPath;} else {
http_response_code(404);
exit('视频地址不存在');}// 检查文件是否存在if (!file_exists($videoPath) || !is_file($videoPath)) {
http_response_code(404);
exit('视频文件不存在');}// 获取文件信息$fileSize = filesize($videoPath);$mimeType = mime_content_type($videoPath); // 需要开启fileinfo扩展// 解析Range请求头(格式:bytes=0-1023 或 bytes=1024-)$range = $_SERVER['HTTP_RANGE'] ?? '';$start = 0;$end = $fileSize - 1;if ($range && strpos($range, 'bytes=') === 0) {
$range = substr($range, 6);
$rangeArr = explode('-', $range);
$start = intval($rangeArr[0]);
if (isset($rangeArr[1]) && $rangeArr[1] !== '') {
$end = intval($rangeArr[1]);
}
// 修正范围(避免超出文件大小)
$start = min($start, $end);
$end = min($end, $fileSize - 1);}// 计算响应长度$responseLength = $end - $start + 1;// 设置响应头(关键:支持断点续传)http_response_code(206); // Partial Contentheader("Content-Type: {$mimeType}");header("Content-Length: {$responseLength}");header("Content-Range: bytes {$start}-{$end}/{$fileSize}");header("Accept-Ranges: bytes");header("Cache-Control: public, max-age=86400"); // 缓存1天,减少重复请求// 流式输出视频数据(避免一次性读取大文件到内存)$bufferSize = 4096; // 4KB缓冲区$fp = fopen($videoPath, 'rb');fseek($fp, $start); // 定位到起始字节while ($responseLength > 0 && !feof($fp)) {
$readLength = min($bufferSize, $responseLength);
$data = fread($fp, $readLength);
echo $data;
flush(); // 强制输出缓冲区
$responseLength -= $readLength;}fclose($fp);exit;?>(3)权限控制(可选)
<?php/**
* 带权限验证的播放接口
*/header("Access-Control-Allow-Origin: *");// 1. 验证Token(生产环境需从数据库/Redis查询合法Token)$token = $_GET['token'] ?? '';$validToken = 'your-valid-token'; // 实际应从用户表获取if ($token !== $validToken) {
http_response_code(403);
exit('无播放权限');}// 2. 后续逻辑同「分片播放接口」$videoStoragePath = '/www/video-storage/';$fileName = $_GET['fileName'] ?? '';// ...(省略文件存在校验、Range解析、流式输出等逻辑)?>3. Nginx 配置优化(关键)
server {
listen 80;
server_name your-domain.com; # 你的域名
# 静态资源直接访问(视频文件)
location ~* ^/video/storage/ {
alias /www/video-storage/; # 视频存储目录绝对路径
expires 1d; # 缓存1天
add_header Cache-Control "public, max-age=86400";
# 支持Range请求(Nginx原生支持,无需额外代码)
add_header Accept-Ranges bytes;
# 禁止直接访问目录
autoindex off;
}
# 动态接口(上传、权限验证播放)
location ~* ^/video/api/ {
proxy_pass http://127.0.0.1:9000; # PHP-FPM地址
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 支持大文件上传(配合PHP的upload_max_filesize)
client_max_body_size 1024m;
}
# PHP-FPM配置
location ~ \.php$ {
root /www/your-project/; # 项目根目录
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}}三、前端实现(HTML + Video.js)
1. 视频上传组件(分片上传)
<!DOCTYPE html><html lang="zh-CN"><head>
<meta charset="UTF-8">
<title>视频上传</title>
<style>
.progress { width: 500px; height: 20px; border: 1px solid #ccc; margin: 10px 0; }
.progress-bar { height: 100%; background: #42b983; width: 0%; transition: width 0.3s; }
</style></head><body>
<input type="file" id="videoFile" accept="video/*" />
<div id="fileInfo" style="display: none;">
<p>文件名:<span id="fileName"></span></p>
<p>大小:<span id="fileSize"></span></p>
<button onclick="startUpload()">开始上传</button>
<div class="progress">
<div class="progress-bar" id="progressBar"></div>
</div>
<p>进度:<span id="progressText">0%</span></p>
</div>
<script>
const fileInput = document.getElementById('videoFile');
const fileInfo = document.getElementById('fileInfo');
const fileNameEl = document.getElementById('fileName');
const fileSizeEl = document.getElementById('fileSize');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let file = null;
const chunkSize = 4 * 1024 * 1024; // 4MB分片(与后端一致)
// 选择文件后显示信息
fileInput.addEventListener('change', (e) => {
file = e.target.files[0];
if (!file) return;
fileInfo.style.display = 'block';
fileNameEl.textContent = file.name;
fileSizeEl.textContent = formatFileSize(file.size);
});
// 格式化文件大小
function formatFileSize(size) {
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + 'KB';
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + 'MB';
return (size / (1024 * 1024 * 1024)).toFixed(2) + 'GB';
}
// 开始分片上传
async function startUpload() {
if (!file) return alert('请选择视频文件');
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
const fileName = file.name;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// 计算当前分片的起始和结束位置
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, totalSize);
const chunk = file.slice(start, end);
// 构建表单数据
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileName', fileName);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
try {
// 上传分片
await uploadChunk(formData, chunkIndex, totalChunks);
} catch (err) {
alert(`第${chunkIndex + 1}分片上传失败:${err.message}`);
return;
}
}
alert('上传完成!');
}
// 上传单个分片
function uploadChunk(formData, chunkIndex, totalChunks) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://your-domain.com/video/api/upload-shard.php');
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
// 单个分片的上传进度
const chunkProgress = e.loaded / e.total;
// 总进度 = 已完成分片数/总分片数 + 当前分片进度/总分片数
const totalProgress = Math.floor((chunkIndex + chunkProgress) / totalChunks * 100);
progressBar.style.width = `${totalProgress}%`;
progressText.textContent = `${totalProgress}%`;
}
});
xhr.onload = () => {
const res = JSON.parse(xhr.responseText);
if (res.code === 200) resolve(res);
else reject(new Error(res.msg));
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(formData);
});
}
</script></body></html>2. 视频播放组件(支持普通格式 + HLS)
<!DOCTYPE html><html lang="zh-CN"><head>
<meta charset="UTF-8">
<title>视频播放</title>
<!-- 引入Video.js样式和脚本 -->
<link href="https://vjs.zencdn.net/8.6.1/video-js.css" rel="stylesheet">
<script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
<!-- 引入HLS插件(支持.m3u8格式) -->
<script src="https://cdn.jsdelivr.net/npm/videojs-contrib-hls@5.15.0/dist/videojs-contrib-hls.min.js"></script>
<style>
.video-container { width: 100%; max-width: 1200px; margin: 0 auto; }
</style></head><body>
<div class="video-container">
<video
id="videoPlayer"
class="video-js vjs-big-play-centered vjs-fluid"
controls
preload="auto"
poster="https://via.placeholder.com/1200x675?text=视频封面"
></video>
</div>
<script>
// 初始化播放器
const player = videojs('videoPlayer', {
autoplay: false, // 禁止自动播放(浏览器政策限制)
controls: true, // 显示控制栏
responsive: true, // 响应式布局
fluid: true, // 自适应容器大小
playbackRates: [0.5, 1.0, 1.5, 2.0] // 倍速播放选项
});
// 播放地址(后端返回的地址,支持MP4或HLS)
const videoType = 'hls'; // 可选:mp4/hls
let videoUrl = '';
if (videoType === 'mp4') {
// 普通MP4格式(Nginx直接访问静态资源,更高效)
videoUrl = 'http://your-domain.com/video/storage/xxx.mp4';
player.src({ src: videoUrl, type: 'video/mp4' });
} else if (videoType === 'hls') {
// HLS格式(.m3u8索引文件)
videoUrl = 'http://your-domain.com/video/storage/hls/xxx/index.m3u8';
player.src({ src: videoUrl, type: 'application/x-mpegURL' });
}
// 错误处理
player.on('error', (err) => {
console.error('播放失败:', player.error().message);
alert('视频播放失败,请检查地址是否有效');
});
</script></body></html>四、生产环境优化与部署
1. 性能优化
存储优化:放弃本地存储,使用阿里云 OSS / 腾讯云 COS,配置 CDN 加速(如阿里云 CDN、腾讯云 CDN),降低源站压力,提升跨地域访问速度。
转码优化:
批量转码:用 FFmpeg 将视频转为多码率(720p、1080p)的 HLS 格式,支持弱网自动切换码率。
异步转码:上传完成后通过消息队列(如 RabbitMQ)或定时任务处理转码,避免阻塞上传接口。
缓存优化:
视频元信息(大小、格式、播放地址)缓存到 Redis,减少数据库查询。
Nginx 配置
Cache-Control和Expires头,缓存视频分片和 HLS 索引文件。防盗链与限流:
防盗链:Nginx 配置
valid_referers,限制仅指定域名可访问视频。限流:用 Nginx 的
limit_req模块限制单 IP 请求频率,避免恶意刷流量。
2. 部署注意事项
PHP 配置调整:
php.ini中设置upload_max_filesize = 1024M、post_max_size = 1024M、max_execution_time = 300(支持大文件上传)。Nginx 配置调整:
client_max_body_size 1024m(允许大文件上传)。开启
gzip压缩(针对 HLS 索引文件,不压缩视频文件)。安全配置:
视频存储目录设置为非 Web 根目录,避免直接访问。
播放接口添加 Token 验证,防止视频被非法盗链播放。
定期备份视频文件(云存储自带备份功能,可开启)。
3. 常见问题排查
视频无法播放:
检查视频格式(优先 MP4/HLS)和 MIME 类型是否正确。
检查跨域配置(前端和后端是否都允许跨域)。
检查 Nginx 是否支持 Range 请求(确保配置中没有禁用
Accept-Ranges)。分片上传失败:
检查服务器磁盘空间是否充足。
检查分片大小是否与后端一致。
检查网络稳定性(大文件上传建议用 HTTPS)。
HLS 转码失败:
确认 FFmpeg 已安装且可正常执行。
检查视频文件是否损坏,尝试重新上传。
查看转码日志(可将 FFmpeg 输出重定向到日志文件)。