.net8使用FFmpeg获取视频封面、添加水印、转M3U8格式

1、下载FFmpeg

https://www.gyan.dev/ffmpeg/builds/

image

 2、安装FFmpeg

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

image

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

ffmpeg -version

 

image

 3、.net 项目nuget安装FFMpegCore

image

4、.net项目的Program.cs配置ffmpeg路径

Program.cs

using FFMpegCore;

// 配置 FFmpeg 可执行文件路径
GlobalFFOptions.Configure(new FFOptions
{
    BinaryFolder = @"D:\ffmpeg\bin"
});

image

 

 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";

image

6、如果用IIS也记得添加.m3u8的MIME映射

文件护展名:

.m3u8

application/x-mpegURL

image

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; }
 }

 

posted @ 2026-04-27 09:43  ziff123  阅读(3)  评论(0)    收藏  举报