2026-05-22 17:01:49 +08:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
|
|
namespace FileShare_Services.Services.FileLibrary
|
|
|
|
|
{
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// 视频缩略图服务实现,使用 ffmpeg 截取视频帧作为缩略图,使用 ffprobe 提取视频时长。
|
|
|
|
|
/// </summary>
|
2026-05-22 17:01:49 +08:00
|
|
|
public sealed class VideoThumbnailService : IVideoThumbnailService
|
|
|
|
|
{
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>缩略图文件存储目录的绝对路径。</summary>
|
2026-05-22 17:01:49 +08:00
|
|
|
private readonly string _thumbnailDir;
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>ffmpeg 可执行文件路径。</summary>
|
2026-05-22 17:01:49 +08:00
|
|
|
private readonly string _ffmpegPath;
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>ffprobe 可执行文件路径。</summary>
|
2026-05-22 17:01:49 +08:00
|
|
|
private readonly string _ffprobePath;
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// 初始化视频缩略图服务,解析并验证配置路径,创建缩略图存储目录。
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="options">缩略图存储配置选项。</param>
|
2026-05-22 17:01:49 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <inheritdoc />
|
2026-05-22 17:01:49 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <inheritdoc />
|
2026-05-22 17:01:49 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// 解析可执行文件路径,支持绝对路径和相对于应用程序基目录的相对路径。
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="executablePath">配置中的原始可执行文件路径。</param>
|
|
|
|
|
/// <returns>可用的绝对路径。</returns>
|
2026-05-22 17:01:49 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <inheritdoc />
|
2026-05-22 17:01:49 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:11:11 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// 计算视频文件路径的 MD5 哈希值,用于生成唯一的缩略图文件名。
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="videoPath">视频文件的绝对路径。</param>
|
|
|
|
|
/// <returns>小写的十六进制 MD5 哈希字符串。</returns>
|
2026-05-22 17:01:49 +08:00
|
|
|
public string ComputeHash(string videoPath) =>
|
|
|
|
|
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(videoPath))).ToLowerInvariant();
|
|
|
|
|
}
|
|
|
|
|
}
|