using SixLabors.ImageSharp; using FFMpegCore; using FFMpegCore.Pipes; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Services { public interface IThumbnailService { Task GenerateThumbnailAsync(Stream sourceStream, Guid uploaderId, string? mimeType = null); Task GetThumbnailAsync(string fileIdentifier); bool SupportsThumbnail(string mimeType); } public class ThumbnailResult { public bool Success { get; set; } public string? FileIdentifier { get; set; } public int? SourceWidth { get; set; } public int? SourceHeight { get; set; } public int? DurationSeconds { get; set; } public string? ErrorMessage { get; set; } } public class ThumbnailService : IThumbnailService { private readonly IFileStorageService _fileStorageService; private readonly ILogger _logger; private readonly int _maxWidth; private readonly int _maxHeight; private const int WebPQuality = 75; private const string ThumbnailContentType = "image/webp"; private const string ThumbnailExtension = "webp"; private static readonly HashSet SupportedImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp" }; private static readonly HashSet SupportedVideoTypes = new(StringComparer.OrdinalIgnoreCase) { "video/mp4", "video/webm", "video/quicktime", "application/x-shockwave-flash" }; public ThumbnailService( IFileStorageService fileStorageService, IConfiguration configuration, ILogger logger) { _fileStorageService = fileStorageService; _logger = logger; _maxWidth = configuration.GetValue("Thumbnails:MaxWidth", 300); _maxHeight = configuration.GetValue("Thumbnails:MaxHeight", 300); } public bool SupportsThumbnail(string mimeType) { return SupportedImageTypes.Contains(mimeType) || SupportedVideoTypes.Contains(mimeType); } public async Task GenerateThumbnailAsync( Stream sourceStream, Guid uploaderId, string? mimeType = null) { if (mimeType != null && SupportedVideoTypes.Contains(mimeType)) return await GenerateVideoThumbnailAsync(sourceStream, uploaderId); return await GenerateImageThumbnailAsync(sourceStream, uploaderId); } private async Task GenerateImageThumbnailAsync(Stream sourceStream, Guid uploaderId) { var tempInput = Path.GetTempFileName(); var tempOutput = Path.ChangeExtension(Path.GetTempFileName(), ".webp"); try { if (sourceStream.CanSeek) sourceStream.Position = 0; await using (var fs = File.Create(tempInput)) { await sourceStream.CopyToAsync(fs); } int sourceWidth, sourceHeight; bool isAnimated; int? durationSeconds = null; using (var image = await Image.LoadAsync(tempInput)) { sourceWidth = image.Width; sourceHeight = image.Height; isAnimated = image.Frames.Count > 1; } if (isAnimated) { try { var mediaInfo = await FFProbe.AnalyseAsync(tempInput); durationSeconds = (int)mediaInfo.Duration.TotalSeconds; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to extract duration for animated image {Input}", tempInput); } } var scaleFilter = $"scale='min({_maxWidth},iw)':'min({_maxHeight},ih)':force_original_aspect_ratio=decrease"; if (isAnimated) { await FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .WithCustomArgument($"-vf {scaleFilter}") .WithCustomArgument($"-quality {WebPQuality} -loop 0") .ForceFormat("webp")) .ProcessAsynchronously(); } else { await FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .WithCustomArgument($"-vf {scaleFilter}") .WithFrameOutputCount(1) .WithCustomArgument($"-quality {WebPQuality}") .ForceFormat("webp")) .ProcessAsynchronously(); } await using var thumbnailStream = File.OpenRead(tempOutput); var result = await _fileStorageService.SaveFileAsync( thumbnailStream, $"thumbnail.{ThumbnailExtension}", new FileStorageOptions { ContentType = ThumbnailContentType, UploaderId = uploaderId, IsPublic = true }); if (!result.Success) { return new ThumbnailResult { Success = false, ErrorMessage = result.ErrorMessage }; } _logger.LogInformation( "Thumbnail generated: {Identifier} (source {Width}x{Height}, animated={Animated})", result.FileIdentifier, sourceWidth, sourceHeight, isAnimated); return new ThumbnailResult { Success = true, FileIdentifier = result.FileIdentifier, SourceWidth = sourceWidth, SourceHeight = sourceHeight, DurationSeconds = durationSeconds }; } catch (Exception ex) { _logger.LogError(ex, "Failed to generate image thumbnail"); return new ThumbnailResult { Success = false, ErrorMessage = ex.Message }; } finally { TryDeleteFile(tempInput); TryDeleteFile(tempOutput); } } private async Task GenerateVideoThumbnailAsync(Stream sourceStream, Guid uploaderId) { var tempInput = Path.GetTempFileName(); var tempOutput = Path.ChangeExtension(Path.GetTempFileName(), ".webp"); try { if (sourceStream.CanSeek) sourceStream.Position = 0; await using (var fs = File.Create(tempInput)) { await sourceStream.CopyToAsync(fs); } var mediaInfo = await FFProbe.AnalyseAsync(tempInput); var duration = mediaInfo.Duration; var videoStream = mediaInfo.PrimaryVideoStream; if (videoStream == null) { return new ThumbnailResult { Success = false, ErrorMessage = "No video stream found in file" }; } var captureTime = TimeSpan.FromSeconds(Math.Min(1, duration.TotalSeconds * 0.1)); var scaleFilter = $"scale='min({_maxWidth},iw)':'min({_maxHeight},ih)':force_original_aspect_ratio=decrease"; await FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .Seek(captureTime) .WithCustomArgument($"-vf {scaleFilter}") .WithFrameOutputCount(1) .WithCustomArgument($"-quality {WebPQuality}") .ForceFormat("webp")) .ProcessAsynchronously(); await using var thumbnailStream = File.OpenRead(tempOutput); var result = await _fileStorageService.SaveFileAsync( thumbnailStream, $"thumbnail.{ThumbnailExtension}", new FileStorageOptions { ContentType = ThumbnailContentType, UploaderId = uploaderId, IsPublic = true }); if (!result.Success) { return new ThumbnailResult { Success = false, ErrorMessage = result.ErrorMessage }; } _logger.LogInformation( "Video thumbnail generated: {Identifier} (source {Width}x{Height}, duration={Duration}s)", result.FileIdentifier, videoStream.Width, videoStream.Height, (int)duration.TotalSeconds); return new ThumbnailResult { Success = true, FileIdentifier = result.FileIdentifier, SourceWidth = videoStream.Width, SourceHeight = videoStream.Height, DurationSeconds = (int)duration.TotalSeconds }; } catch (Exception ex) { _logger.LogError(ex, "Failed to generate video thumbnail"); return new ThumbnailResult { Success = false, ErrorMessage = ex.Message }; } finally { TryDeleteFile(tempInput); TryDeleteFile(tempOutput); } } public async Task GetThumbnailAsync(string fileIdentifier) { return await _fileStorageService.GetFileAsync(fileIdentifier); } private static void TryDeleteFile(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { } } } }