视频转码与切片(HLS)完整教程
一、前置准备
1. 安装 FFmpeg
下载并安装 FFmpeg:
-
Windows:下载后解压,将 `bin` 目录添加到系统 PATH,或将 `ffmpeg.exe` 路径配置到环境变量 `FFMPEG_PATH`
2. 项目依赖
在 `.csproj` 中添加(如需要中文转拼音水印):
```xml
<PackageReference Include="Pinyin4net" Version="1.0.0" />
```
3. 命名空间引用
```csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using NP.PE.WorkSpace.Helper; // FFMPEGHelper 所在命名空间
using NP.PE.WorkSpace.Model;
using NP.PE.WorkSpace.Model.ResponseModel;
```
二、核心概念
转码 vs 切片
-
转码:将视频从一种编码格式转换为另一种(如 MP4 → H.264)
-
切片:将视频切分成多个小文件(TS 切片),生成播放列表(m3u8)
通常一起进行:转码的同时进行切片,生成 HLS 格式。
三、实现步骤
步骤 1:创建 FFMPEGHelper 辅助类
创建 `FFMPEGHelper.cs`:
```csharp
namespace NP.PE.WorkSpace.Helper
{
public class FFMPEGHelper
{
// FFmpeg 路径(从环境变量或默认路径获取)
private static readonly string _ffmpeg_path = GetFFmpegPath();
// 获取 FFmpeg 路径的方法
private static string GetFFmpegPath()
{
// 1. 优先从环境变量读取
var envPath = Environment.GetEnvironmentVariable("FFMPEG_PATH");
if (!string.IsNullOrWhiteSpace(envPath) && File.Exists(envPath))
{
return envPath;
}
// 2. 尝试系统 PATH 中的 ffmpeg
return "ffmpeg";
}
// 验证 FFmpeg 路径
private static void ValidateFFmpegPath()
{
if (string.IsNullOrWhiteSpace(_ffmpeg_path))
{
throw new FileNotFoundException("FFmpeg 路径未配置");
}
}
}
}
```
步骤 2:实现转码切片方法
在 `FFMPEGHelper` 中添加转码切片方法:
```csharp
/// <summary>
/// 执行 FFmpeg 命令(通用方法)
/// </summary>
public static ResponseContent SendCommand(string command)
{
try
{
ValidateFFmpegPath();
}
catch (FileNotFoundException ex)
{
return ResponseContent.Error(ErrorCode.OPERATION_FAILED, ex.Message);
}
Process process = new Process();
process.StartInfo.FileName = _ffmpeg_path;
process.StartInfo.Arguments = command;
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.StandardOutputEncoding = Encoding.UTF8;
process.StartInfo.StandardErrorEncoding = Encoding.UTF8;
process.Start();
process.WaitForExit();
if (process.ExitCode != 0)
{
return ResponseContent.Error(ErrorCode.OPERATION_FAILED, "FFmpeg 执行失败");
}
return ResponseContent.OK();
}
```
步骤 3:在 BLL 层实现转码逻辑
在 `CaseBLL.cs` 或 `VideoBLL.cs` 中实现:
```csharp
// 常量定义
private const int DEFAULT_SEGMENT_TIME_SECONDS = 10; // 每个切片10秒
/// <summary>
/// 对视频进行HLS切片转码
/// </summary>
private ResponseContent<bool> TranscodeCaseVideo(
Guid mediaId,
string originalFilePath,
string originalRelativePath)
{
try
{
// 1. 验证原始文件是否存在
if (!File.Exists(originalFilePath))
{
return ResponseContent<bool>.Error("原始视频文件不存在");
}
// 2. 获取视频文件所在目录
var videoDirectory = Path.GetDirectoryName(originalFilePath);
if (string.IsNullOrWhiteSpace(videoDirectory))
{
return ResponseContent<bool>.Error("无法获取视频文件目录");
}
// 3. 创建 hls 目录
var hlsDirectory = Path.Combine(videoDirectory, "hls");
if (!Directory.Exists(hlsDirectory))
{
Directory.CreateDirectory(hlsDirectory);
}
// 4. 定义输出文件路径
var playlistFileName = "index.m3u8"; // 播放列表文件名
var playlistFullPath = Path.Combine(hlsDirectory, playlistFileName);
var segmentTemplate = Path.Combine(hlsDirectory, "segment_%03d.ts"); // TS切片模板
// 5. 构建 FFmpeg 命令
// 参数说明:
// -y : 自动覆盖已存在的文件
// -i : 输入文件路径
// -c:v libx264 : 视频编码器使用 H.264
// -c:a aac : 音频编码器使用 AAC
// -preset fast : 编码速度预设(fast/medium/slow)
// -f segment : 输出格式为分段
// -segment_time : 每个切片的时长(秒)
// -segment_list : m3u8 播放列表路径
var arguments =
$"-y -i \"{originalFilePath}\" " +
"-c:v libx264 -c:a aac -preset fast " +
$"-f segment -segment_time {DEFAULT_SEGMENT_TIME_SECONDS} " +
$"-segment_list \"{playlistFullPath}\" \"{segmentTemplate}\"";
// 6. 执行 FFmpeg 转码
var ffResult = FFMPEGHelper.SendCommand(arguments);
if (!ffResult.Success)
{
return ResponseContent<bool>.Error($"视频转码失败: {ffResult.Message}");
}
// 7. 修正 m3u8 文件中的 TS 切片路径(改为 API 路径)
FixM3u8SegmentPaths(playlistFullPath, hlsDirectory);
// 8. 获取视频信息(时长等)
int? duration = null;
var durationResult = FFMPEGHelper.GetVideoDuration(originalFilePath);
if (durationResult.Success && durationResult.Data.HasValue)
{
duration = (int)Math.Round(durationResult.Data.Value);
}
// 9. 计算切片数量
var segmentCount = Directory.GetFiles(hlsDirectory, "segment_*.ts").Length;
// 10. 将绝对路径转换为相对路径(存数据库)
var relativeM3u8 = StoragePathHelper.ToRelativePath(playlistFullPath);
// 11. 更新数据库,保存 m3u8 路径
var updateSql = $@"
UPDATE TbMedia
SET mediaUrl = @M3u8Path, duration = @Duration, UpdateTime = @UpdateTime
WHERE Id = @Id";
var updateParams = new Dictionary<string, object>
{
{ "@Id", mediaId },
{ "@M3u8Path", relativeM3u8 },
{ "@Duration", duration ?? 0 },
{ "@UpdateTime", DateTime.Now }
};
var updateResult = ExecuteNonQuery(updateSql, updateParams, SystemEnum.KNOWLEDGEBASE);
if (updateResult <= 0)
{
return ResponseContent<bool>.Error("更新媒体信息失败");
}
return ResponseContent<bool>.OK(true);
}
catch (Exception ex)
{
return ResponseContent<bool>.Error($"视频转码异常: {ex.Message}");
}
}
```
步骤 4:修正 m3u8 文件中的 TS 路径
```csharp
/// <summary>
/// 修正 m3u8 文件中的 TS 切片路径,将相对路径改为完整的 API 路径
/// </summary>
private static void FixM3u8SegmentPaths(string m3u8FilePath, string hlsDirectory)
{
if (!File.Exists(m3u8FilePath))
{
return;
}
try
{
// 1. 读取 m3u8 文件内容
var m3u8Content = File.ReadAllText(m3u8FilePath, Encoding.UTF8);
// 2. 将 hls 目录转换为相对路径
var relativeHlsDir = StoragePathHelper.ToRelativePath(hlsDirectory);
// 3. 逐行处理,替换 TS 切片路径为 API 路径
var lines = m3u8Content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
var modifiedLines = new List<string>();
foreach (var line in lines)
{
var trimmedLine = line.Trim();
// 检查是否是 TS 切片文件行(以 .ts 结尾且不是注释行)
if (trimmedLine.EndsWith(".ts", StringComparison.OrdinalIgnoreCase) &&
!trimmedLine.StartsWith("#") &&
!trimmedLine.Contains("file="))
{
// TS 切片文件名
var tsFileName = trimmedLine;
var tsFilePath = $"{relativeHlsDir}/{tsFileName}";
// 修改为 API 路径格式
modifiedLines.Add($"/api/case/GetCaseMediaFile?file={Uri.EscapeDataString(tsFilePath)}");
}
else
{
// 其他行(注释、配置等)保持不变
modifiedLines.Add(line);
}
}
// 4. 写回 m3u8 文件
File.WriteAllText(m3u8FilePath, string.Join("\n", modifiedLines), Encoding.UTF8);
}
catch
{
// 如果修改失败,静默失败(不影响播放)
}
}
```
步骤 5:在保存视频时触发转码
在保存视频文件的方法中,如果是视频类型,自动触发转码:
```csharp
// 保存视频文件后
if (mediaType == 3) // 3-视频
{
// 保存文件到数据库后,触发转码
var transcodeResult = TranscodeCaseVideo(mediaId, filePath, mediaUrl);
if (!transcodeResult.Success)
{
// 转码失败不影响整体流程,记录日志即可
Console.WriteLine($"[CaseBLL] 视频转码失败: MediaId={mediaId}, Error={transcodeResult.Message}");
}
}
```
四、FFmpeg 命令参数详解
基础转码切片命令
```bash
ffmpeg -y -i "输入视频.mp4" \
-c:v libx264 \ # 视频编码器:H.264
-c:a aac \ # 音频编码器:AAC
-preset fast \ # 编码速度:fast/medium/slow
-f segment \ # 输出格式:分段
-segment_time 10 \ # 每个切片时长:10秒
-segment_list "index.m3u8" \ # m3u8 播放列表路径
"segment_%03d.ts" # TS 切片文件名模板(%03d = 001, 002, 003...)
```
参数说明
| 参数 | 说明 | 示例 |
|------|------|------|
| `-y` | 自动覆盖已存在文件 | `-y` |
| `-i` | 输入文件路径 | `-i "video.mp4"` |
| `-c:v` | 视频编码器 | `libx264` (H.264) |
| `-c:a` | 音频编码器 | `aac` |
| `-preset` | 编码速度 | `fast` / `medium` / `slow` |
| `-f segment` | 分段输出格式 | `-f segment` |
| `-segment_time` | 切片时长(秒) | `10` |
| `-segment_list` | m3u8 播放列表路径 | `"index.m3u8"` |
| `segment_%03d.ts` | TS 切片文件名模板 | `segment_000.ts`, `segment_001.ts`... |
五、文件结构示例
转码后的文件结构:
```
视频文件夹/
├── source.mp4 # 原始视频文件
└── hls/ # HLS 切片目录
├── index.m3u8 # 播放列表文件
├── segment_000.ts # TS 切片 1
├── segment_001.ts # TS 切片 2
├── segment_002.ts # TS 切片 3
└── ...
```
六、m3u8 文件格式示例
```m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
/api/case/GetCaseMediaFile?file=05-案例/视频/视频名/hls/segment_000.ts
#EXTINF:10.0,
/api/case/GetCaseMediaFile?file=05-案例/视频/视频名/hls/segment_001.ts
#EXTINF:10.0,
/api/case/GetCaseMediaFile?file=05-案例/视频/视频名/hls/segment_002.ts
#EXT-X-ENDLIST
```
七、完整流程总结
-
上传视频文件 → 保存到指定目录
-
创建 hls 子目录
-
调用 FFmpeg 进行转码切片
-
生成 `index.m3u8` 和多个 `segment_*.ts` 文件
-
修正 m3u8 中的 TS 路径为 API 路径
-
获取视频信息(时长、分辨率等)
-
更新数据库,保存 m3u8 路径
-
前端通过 m3u8 路径播放视频
八、常见问题
1. FFmpeg 路径找不到
-
设置环境变量 `FFMPEG_PATH`
-
或将 `ffmpeg.exe` 放在项目 `tools/ffmpeg/` 目录下
2. 转码失败
-
检查输入文件是否存在
-
检查输出目录是否有写入权限
-
查看 FFmpeg 错误日志
3. m3u8 播放失败
-
检查 TS 切片路径是否正确
-
检查 API 接口是否允许匿名访问
-
检查 CORS 响应头是否正确设置
九、扩展功能
带水印的转码
```csharp
// 使用 FFMPEGHelper.TranscodeWithWatermark 方法
var ffResult = FFMPEGHelper.TranscodeWithWatermark(
inputPath, // 输入视频路径
playlistPath, // m3u8 路径
segmentTemplate, // TS 切片模板
segmentTime, // 切片时长
watermarkText, // 水印文本
watermarkPosition // 水印位置:center/top-left/top-right/bottom-left/bottom-right
);
```
以上是完整的实现流程。按步骤实现即可完成视频转码和切片功能。