首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >PHP高性能Webman框架FFmpeg实现实时视频流HLS

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

作者头像
Tinywan
发布2026-07-01 16:52:57
发布2026-07-01 16:52:57
830
举报
文章被收录于专栏:开源技术小栈开源技术小栈

简介

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

什么是 HLS?

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

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

什么是 FFmpeg?

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

实战案例

安装 FFmpeg

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

代码语言:javascript
复制
# 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

安装完成后,验证安装:

代码语言:javascript
复制
ffmpeg -version

创建 Webman 项目

代码语言:javascript
复制
composer create-project workerman/webman video-streaming
cd video-streaming

项目结构:

代码语言:javascript
复制
video-streaming/
├── app/
│   ├── controller/
│   │   └── VideoController.php
│   └── view/
│       └── player.html
├── config/
│   └── route.php
├── public/
└── runtime/
    └── videos/          # 视频存储目录
        └── hls/         # HLS转码输出目录
FFmpeg HLS 转码命令

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

代码语言:javascript
复制
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

输出文件名

执行后生成的文件结构:

代码语言:javascript
复制
output/
├── index.m3u8
├── index0.ts
├── index1.ts
├── index2.ts
└── ...

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

代码语言:javascript
复制
#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

代码语言:javascript
复制
<?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

代码语言:javascript
复制
<?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(如果没有则创建):

代码语言:javascript
复制
<?php
return [
    // 静态文件路由映射
    'map' => [
        '/streaming' => runtime_path() . 'videos',
    ],
];

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

代码语言:javascript
复制
<?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 中注册中间件:

代码语言:javascript
复制
<?php
return [
    '' => [
        app\middleware\StreamingAccess::class,
    ],
];

2.3 播放转码后的视频

下载 hls.js

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

代码语言:javascript
复制
https://cdn.jsdelivr.net/npm/hls.js@latest

将文件保存到 public/hls.js

创建播放器页面

创建 app/view/player.html

代码语言:javascript
复制
<!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">&times;</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>

2.4 测试运行

启动 Webman 服务
代码语言:javascript
复制
cd video-streaming
php windows.php  # Windows
# 或
php start.php start  # Linux

默认监听 http://0.0.0.0:8787

测试上传接口

使用 Postman 或 curl 测试:

代码语言:javascript
复制
curl -X POST http://localhost:8787/videos/upload \
  -F "file=@/path/to/your/video.mp4"

响应示例:

代码语言:javascript
复制
{
    "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 可以轻松实现:

代码语言:javascript
复制
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 多分辨率自适应

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

代码语言:javascript
复制
// 在 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

代码语言:javascript
复制
<?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 中注册:

代码语言:javascript
复制
<?php
return [
    'transcode_queue' => [
        'handler' => app\process\TranscodeQueue::class,
        'listen' => 'text://0.0.0.0:8788',
        'count' => 1,  // 单进程处理,避免并发问题
    ],
];

3.4 视频加密(DRM)

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

代码语言:javascript
复制
// 生成加密密钥
$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:

代码语言:javascript
复制
// 接收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. 完整项目结构

最终的项目文件结构:

代码语言:javascript
复制
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                        # 启动文件

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-05-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 开源技术小栈 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 简介
  • 什么是 HLS?
  • 什么是 FFmpeg?
  • 实战案例
    • 安装 FFmpeg
    • 创建 Webman 项目
      • FFmpeg HLS 转码命令
    • 2.2 上传视频并转码
      • 配置路由
      • 控制器实现
      • 配置静态资源访问
    • 2.3 播放转码后的视频
      • 下载 hls.js
      • 创建播放器页面
    • 2.4 测试运行
      • 启动 Webman 服务
      • 测试上传接口
      • 访问播放器
  • 3. 进阶功能
    • 3.1 生成缩略图
    • 3.2 多分辨率自适应
    • 3.3 异步转码队列
    • 3.4 视频加密(DRM)
    • 3.5 实时直播流
  • 4. 完整项目结构
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档