2026-05-22 17:11:11 +08:00

160 lines
6.4 KiB
C#

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
namespace FileShare_Services.Services.FileLibrary
{
/// <summary>
/// 视频缩略图服务实现,使用 ffmpeg 截取视频帧作为缩略图,使用 ffprobe 提取视频时长。
/// </summary>
public sealed class VideoThumbnailService : IVideoThumbnailService
{
/// <summary>缩略图文件存储目录的绝对路径。</summary>
private readonly string _thumbnailDir;
/// <summary>ffmpeg 可执行文件路径。</summary>
private readonly string _ffmpegPath;
/// <summary>ffprobe 可执行文件路径。</summary>
private readonly string _ffprobePath;
/// <summary>
/// 初始化视频缩略图服务,解析并验证配置路径,创建缩略图存储目录。
/// </summary>
/// <param name="options">缩略图存储配置选项。</param>
public VideoThumbnailService(ThumbnailStorageOptions options)
{
_thumbnailDir = Path.IsPathRooted(options.RootPath)
? options.RootPath
: Path.Combine(AppContext.BaseDirectory, options.RootPath);
_ffmpegPath = ResolveExecutablePath(options.FfmpegPath);
_ffprobePath = ResolveExecutablePath(options.FfprobePath);
Directory.CreateDirectory(_thumbnailDir);
}
/// <inheritdoc />
public Task<GeneratedThumbnail?> GenerateThumbnailAsync(int libraryRootId, string videoPath, CancellationToken ct = default)
{
var hash = ComputeHash(videoPath);
var relativePath = Path.Combine($"root-{libraryRootId}", $"{hash}.jpg");
var outputPath = GetAbsolutePath(relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
if (File.Exists(outputPath))
return Task.FromResult<GeneratedThumbnail?>(new GeneratedThumbnail(relativePath, "image/jpeg"));
try
{
var args = $"-ss 00:00:01 -i \"{videoPath}\" -vframes 1 -q:v 5 -vf \"scale=320:-2\" \"{outputPath}\" -y";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _ffmpegPath,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
},
EnableRaisingEvents = true,
};
process.Start();
using (ct.Register(() =>
{
try { process.Kill(); } catch { /* 进程可能已退出 */ }
}))
{
process.WaitForExit(10000);
}
if (File.Exists(outputPath) && new FileInfo(outputPath).Length > 0)
return Task.FromResult<GeneratedThumbnail?>(new GeneratedThumbnail(relativePath, "image/jpeg"));
return Task.FromResult<GeneratedThumbnail?>(null);
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "生成视频缩略图失败 {VideoPath}", videoPath);
return Task.FromResult<GeneratedThumbnail?>(null);
}
}
/// <inheritdoc />
public string GetAbsolutePath(string relativePath)
{
var root = Path.GetFullPath(_thumbnailDir);
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Thumbnail path is outside of the configured storage root.");
}
return fullPath;
}
/// <summary>
/// 解析可执行文件路径,支持绝对路径和相对于应用程序基目录的相对路径。
/// </summary>
/// <param name="executablePath">配置中的原始可执行文件路径。</param>
/// <returns>可用的绝对路径。</returns>
private static string ResolveExecutablePath(string executablePath)
{
if (Path.IsPathRooted(executablePath))
{
return executablePath;
}
return executablePath.Contains(Path.DirectorySeparatorChar)
|| executablePath.Contains(Path.AltDirectorySeparatorChar)
? Path.Combine(AppContext.BaseDirectory, executablePath)
: executablePath;
}
/// <inheritdoc />
public double? GetVideoDuration(string videoPath)
{
try
{
var args = $"-v error -show_entries format=duration -of csv=p=0 \"{videoPath}\"";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _ffprobePath,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
},
};
process.Start();
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000);
if (double.TryParse(output, out var duration) && duration > 0)
return Math.Round(duration, 1);
return null;
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "获取视频时长失败 {VideoPath}", videoPath);
return null;
}
}
/// <summary>
/// 计算视频文件路径的 MD5 哈希值,用于生成唯一的缩略图文件名。
/// </summary>
/// <param name="videoPath">视频文件的绝对路径。</param>
/// <returns>小写的十六进制 MD5 哈希字符串。</returns>
public string ComputeHash(string videoPath) =>
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(videoPath))).ToLowerInvariant();
}
}