.net8使用FFmpeg获取视频封面、添加水印、转M3U8格式
1、下载FFmpeg
https://www.gyan.dev/ffmpeg/builds/

2、安装FFmpeg
解压出来,设置系统环境变量指向“D:\ffmpeg\bin”即可

可以使用命令查看ffmpeg有没设置成功
ffmpeg -version

3、.net 项目nuget安装FFMpegCore

4、.net项目的Program.cs配置ffmpeg路径
Program.cs
using FFMpegCore;
// 配置 FFmpeg 可执行文件路径
GlobalFFOptions.Configure(new FFOptions
{
BinaryFolder = @"D:\ffmpeg\bin"
});

5、net项目的Program.cs添加.m3u8 文件的 MIME 映射,避免路由找不到.m3u8文件
// 1.创建自定义 ContentTypeProvider var provider = new FileExtensionContentTypeProvider(); // 2. 添加 .m3u8 文件的 MIME 映射 provider.Mappings[".m3u8"] = "application/vnd.apple.mpegurl"; // 3. (可选但建议) 添加 .ts 文件的 MIME 映射 provider.Mappings[".ts"] = "video/mp2t";

6、如果用IIS也记得添加.m3u8的MIME映射
文件护展名:
.m3u8
application/x-mpegURL

7、代码实现
/// <summary> /// 微信小程序上传视频(转码+切片M3U8) /// <para>功能说明:上传视频文件,自动截取第一帧作为封面图,并通过FFmpeg转码为HLS(M3U8+TS)格式,同时添加"壕客多"文字水印。</para> /// <para>处理流程:</para> /// <para>1. 验证文件格式和大小(支持MP4/MPEG/MOV/AVI/WMV/MKV,最大60MB)</para> /// <para>2. 将原始视频保存到临时目录 upload/temp/</para> /// <para>3. 使用FFmpeg截取视频第0秒画面作为封面图,保存为JPG格式</para> /// <para>4. 使用FFmpeg将视频转码为HLS格式(M3U8索引 + TS分片),每个分片约5秒,并添加"壕客多"水印</para> /// <para>5. 转码成功后删除临时原始文件</para> /// <para>6. 返回M3U8播放地址和封面图地址;若转码失败则降级保存原始视频文件</para> /// <para>存储结构:upload/{用户ID}/videos/{日期}/{时间戳}_m3u8/index.m3u8 + segment_*.ts</para> /// <para>封面图路径:upload/{用户ID}/videos/{日期}/{时间戳}_cover.jpg</para> /// </summary> /// <param name="file">上传的视频文件(IFormFile),支持格式:MP4、MPEG、MOV、AVI、WMV、MKV,最大60MB</param> /// <returns> /// 返回 UploadVideoResultDto 对象,包含: /// VideoUrl - 视频播放地址(M3U8格式或降级后的原始文件地址) /// CoverUrl - 视频封面图地址(第一帧截图,转码失败时仍可能返回封面) /// </returns> [HttpPost] [Route("/images/Upload/UploadVideo")] public async Task<MessageModel<UploadVideoResultDto>> UploadVideo(IFormFile file) { // 校验文件是否为空 if (file == null) return Failed<UploadVideoResultDto>("请选择上传的视频文件。"); // 格式限制(允许更多格式,因为会统一转码为M3U8格式播放) var allowType = new string[] { "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", "video/x-ms-wmv", "video/x-matroska" }; // 校验视频格式是否在允许范围内 if (!allowType.Contains(file.ContentType)) return Failed<UploadVideoResultDto>("视频格式错误,支持 MP4、MPEG、MOV、AVI、WMV、MKV 格式"); // 校验视频大小,限制为60MB以内 if (file.Length > 1024 * 1024 * 60) return Failed<UploadVideoResultDto>("视频过大,请确保视频在60MB以内"); // 生成日期和时间戳,用于构建存储路径和文件命名 var nowdate = DateTime.Now.ToString("yyyyMMdd"); var nowTime = DateTime.Now.ToString("yyyyMMddHHmmss"); // ========== 步骤1:保存原始视频到临时目录 ========== // 临时目录位于 wwwroot/upload/temp/,用于存放待转码的原始视频 string tempFolder = Path.Combine(_env.WebRootPath, "upload", "temp"); if (!Directory.Exists(tempFolder)) { Directory.CreateDirectory(tempFolder); } // 获取文件扩展名,构建临时文件路径(命名格式:{时间戳}_src{扩展名}) var fileExt = Path.GetExtension(file.FileName); string tempInputPath = Path.Combine(tempFolder, nowTime + "_src" + fileExt); // 将上传的视频文件写入临时路径 using (var stream = new FileStream(tempInputPath, FileMode.Create, FileAccess.ReadWrite)) { await file.CopyToAsync(stream); } // ========== 步骤2:截取视频第一帧作为封面图 ========== // 封面图保存路径:upload/{用户ID}/videos/{日期}/{时间戳}_cover.jpg string coverUrl = ""; try { string coverFoldername = $"upload/{_user.ID}/videos/{nowdate}"; string coverFolderPath = Path.Combine(_env.WebRootPath, coverFoldername); if (!Directory.Exists(coverFolderPath)) { Directory.CreateDirectory(coverFolderPath); } string coverFileName = $"{nowTime}_cover.jpg"; string coverPath = Path.Combine(coverFolderPath, coverFileName); // 使用FFmpeg截取视频第0秒的画面作为封面图 FFMpeg.Snapshot(tempInputPath, coverPath, null, TimeSpan.FromSeconds(0)); // 封面图生成成功,构建可访问的URL地址 if (System.IO.File.Exists(coverPath)) { string coverRelative = $"{coverFoldername}/{coverFileName}".Replace("\\", "/"); #if DEBUG coverUrl = $"{Request.Scheme}://{Request.Host}/{coverRelative}"; #else coverUrl = $"{Request.Scheme}://{Request.Host}/{AppSettings.ApiPathPrefix}/{coverRelative}"; #endif } } catch (Exception ex) { // 封面截取失败不影响主流程,仅记录警告日志 _logger.LogWarning($"截取视频封面失败:{ex.Message}"); } // ========== 步骤3~6:FFmpeg转码为M3U8格式 ========== try { // ========== 步骤3:准备M3U8输出目录 ========== // 每个视频创建一个独立子目录(命名格式:{时间戳}_m3u8), // 用于存放 index.m3u8 索引文件和 segment_*.ts 分片文件 string foldername = $"upload/{_user.ID}/videos/{nowdate}"; string videoSubDir = $"{nowTime}_m3u8"; string outputDir = Path.Combine(_env.WebRootPath, foldername, videoSubDir); if (!Directory.Exists(outputDir)) { Directory.CreateDirectory(outputDir); } // M3U8索引文件的完整路径 string m3u8Path = Path.Combine(outputDir, "index.m3u8"); // ========== 步骤4:FFmpeg转码 + 切片为M3U8(添加水印"壕客多") ========== // 使用drawtext滤镜在视频右上角添加"壕客多"半透明白色水印 // 按优先级查找系统中文字体文件: // Windows: msyh.ttc(微软雅黑)、msyhbd.ttc(微软雅黑粗体) // Linux: wqy-microhei.ttc(文泉驿微米黑)、NotoSansCJK系列 var fontCandidates = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyhbd.ttc"), "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", }; var fontFile = fontCandidates.FirstOrDefault(f => System.IO.File.Exists(f)) ?? ""; // drawtext的fontfile路径需要转义:反斜杠替换为正斜杠,冒号需转义 var fontFileEscaped = fontFile.Replace("\\", "/").Replace(":", "\\:"); // 构建drawtext滤镜参数:水印文字"壕客多",字号24,白色半透明(alpha=0.5),位于右上角 var drawtextFilter = string.IsNullOrEmpty(fontFile) ? $"drawtext=text='壕客多':fontsize=24:fontcolor=white@0.5:x=w-tw-10:y=10" : $"drawtext=text='壕客多':fontsize=24:fontcolor=white@0.5:x=w-tw-10:y=10:fontfile='{fontFileEscaped}'"; // pad滤镜确保视频宽高为偶数(libx264编码要求),ceil(iw/2)*2 向上取偶 var videoFilter = $"\"{drawtextFilter},pad=ceil(iw/2)*2:ceil(ih/2)*2\""; // FFmpeg转码参数说明: // - 视频编码器:libx264(H.264编码,兼容性最佳) // - 音频编码器:aac(高级音频编码) // - -preset fast:编码速度与压缩率的平衡 // - -pix_fmt yuv420p:像素格式,兼容大部分播放器 // - -vf:视频滤镜(水印 + 尺寸修正) // - 音频码率:128k // - -hls_time 5:每个TS分片约5秒 // - -hls_list_size 0:M3U8列表包含所有分片(不限制数量) // - -hls_segment_filename:TS分片命名格式 segment_001.ts, segment_002.ts, ... // - -hls_flags independent_segments:每个分片可独立解码 await FFMpegArguments .FromFileInput(tempInputPath) .OutputToFile(m3u8Path, false, options => options .WithVideoCodec("libx264") .WithAudioCodec("aac") .WithArgument(new CustomArgument("-preset fast")) .WithArgument(new CustomArgument("-pix_fmt yuv420p")) .WithArgument(new CustomArgument($"-vf {videoFilter}")) .WithAudioBitrate(128) .ForceFormat("hls") .WithArgument(new CustomArgument("-hls_time 5")) .WithArgument(new CustomArgument("-hls_list_size 0")) .WithArgument(new CustomArgument($"-hls_segment_filename \"{Path.Combine(outputDir, "segment_%03d.ts")}\"")) .WithArgument(new CustomArgument("-hls_flags independent_segments")) .OverwriteExisting() ) .ProcessAsynchronously(); // ========== 步骤5:转码成功,删除临时原始文件 ========== if (System.IO.File.Exists(tempInputPath)) { System.IO.File.Delete(tempInputPath); } // ========== 步骤6:构建并返回M3U8播放地址和封面图地址 ========== string m3u8Relative = $"{foldername}/{videoSubDir}/index.m3u8"; #if DEBUG var videoUrl = $"{Request.Scheme}://{Request.Host}/{m3u8Relative.Replace("\\", "/")}"; #else var videoUrl = $"{Request.Scheme}://{Request.Host}/{AppSettings.ApiPathPrefix}/{m3u8Relative.Replace("\\", "/")}"; //var videoUrl = $"{Request.Scheme}://{Request.Host}/meiyeapi/{m3u8Relative.Replace("\\", "/")}"; #endif return Success(new UploadVideoResultDto { VideoUrl = videoUrl, CoverUrl = coverUrl }, "上传成功"); } catch (Exception ex) { // ========== 降级处理:转码失败时直接保存原始视频文件 ========== _logger.LogError($"视频转码失败:{ex.Message}"); // 将临时文件移动到用户视频目录下(保留原始格式,部分设备可能无法播放) string foldername = $"upload/{_user.ID}/videos/{nowdate}"; string folderpath = Path.Combine(_env.WebRootPath, foldername); if (!Directory.Exists(folderpath)) { Directory.CreateDirectory(folderpath); } string fallbackPath = Path.Combine(folderpath, nowTime + fileExt); System.IO.File.Move(tempInputPath, fallbackPath); string fallbackRelative = $"{foldername}/{nowTime}{fileExt}"; #if DEBUG var videoUrl = $"{Request.Scheme}://{Request.Host}/{fallbackRelative.Replace("\\", "/")}"; #else var videoUrl = $"{Request.Scheme}://{Request.Host}/{AppSettings.ApiPathPrefix}/{fallbackRelative.Replace("\\", "/")}"; //var videoUrl = $"{Request.Scheme}://{Request.Host}/meiyeapi/{fallbackRelative.Replace("\\", "/")}"; #endif return Success(new UploadVideoResultDto { VideoUrl = videoUrl, CoverUrl = coverUrl }, "转码失败,已保存原始视频,部分设备可能无法播放"); } }
/// <summary> /// 上传视频返回结果 /// </summary> public class UploadVideoResultDto { /// <summary> /// 视频播放地址(M3U8或原始文件地址) /// </summary> public string VideoUrl { get; set; } /// <summary> /// 视频封面图地址(第一帧截图) /// </summary> public string CoverUrl { get; set; } }

浙公网安备 33010602011771号