using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers; using Microsoft.Extensions.Options; using static Iceshrimp.Backend.Core.Services.ImageProcessing.ImageVersion; namespace Iceshrimp.Backend.Core.Services.ImageProcessing; public class ImageProcessor : ISingletonService { private readonly IOptionsMonitor _config; //TODO: support stripping of exif/icc metadata (without re-encoding) private readonly List _imageProcessors; private readonly ILogger _logger; private readonly SemaphorePlus _semaphore; private readonly int _concurrency; public ImageProcessor( ILogger logger, IOptionsMonitor config, IEnumerable imageProcessors ) { _logger = logger; _config = config; _imageProcessors = imageProcessors.OrderBy(p => p.Priority).ToList(); _concurrency = config.CurrentValue.MediaProcessing.ImageProcessorConcurrency; _semaphore = new SemaphorePlus(Math.Max(_concurrency, 1)); // @formatter:off if (_imageProcessors.Count == 0) _logger.LogInformation("Image processing is disabled as per the configuration."); else if (_imageProcessors.Count == 1) _logger.LogInformation("Using {processor} for image processing.", _imageProcessors[0].DisplayName); else _logger.LogInformation("Using [{processors}] for image processing.", string.Join(", ", _imageProcessors.Select(p => p.DisplayName))); // @formatter:on } public IImageInfo? IdentifyImage(byte[] buf, DriveFileCreationRequest request) { // @formatter:off var ident = RunProcessorAction("ident", p => p.Identify(buf), p => p.CanIdentify, () => throw new Exception("No available image processor supports identifying images")); // @formatter:on // Correct MIME type if ((request.MimeType == "image" && ident?.MimeType != null) || ident?.MimeType == "image/apng") request.MimeType = ident.MimeType; return ident; } public ProcessedImage ProcessImage( byte[] buf, IImageInfo ident, DriveFileCreationRequest request, IReadOnlyCollection formats ) { if (_config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.None || formats.Count == 0) return new ProcessedImage(ident, new MemoryStream(buf), request); // @formatter:off var blurhash = RunProcessorAction("blurhash", p => p.Blurhash(buf, ident), p => p.CanGenerateBlurhash, () => _logger.LogWarning("Skipping blurhash generation: No available image processor supports generating blurhashes"), (p, e) => _logger.LogWarning("Failed to generate blurhash using {processor}: {e}", p, e)); // @formatter:on var results = formats .ToDictionary>?>(p => p, ProcessImageFormat) .AsReadOnly(); return new ProcessedImage(ident) { RequestedFormats = results, Blurhash = blurhash }; Func>? ProcessImageFormat(ImageVersion p) { if (p.Format is ImageFormat.Keep) return () => Task.FromResult(new MemoryStream(buf)); var proc = _imageProcessors.FirstOrDefault(i => i.CanEncode(p.Format)); if (proc == null) { _logger.LogWarning("No image processor supports the format {format}, skipping", p.Format.MimeType); return null; } return async () => { if (_concurrency is 0) return proc.Encode(buf, ident, p.Format); await _semaphore.WaitAsync(); try { return proc.Encode(buf, ident, p.Format); } finally { _semaphore.Release(); } }; } } private T? RunProcessorAction( string name, Func action, Func locator, Action fallback, Action? fallthrough = null ) where T : class { var processors = _imageProcessors.Where(locator).ToImmutableArray(); if (processors.Length == 0) { fallback(); return null; } foreach (var processor in processors) { try { return action(processor); } catch (Exception e) { if (fallthrough != null) { fallthrough(processor, e); } else { _logger.LogWarning("Processor {name} failed to run {action}, falling through...", processor.DisplayName, name); } } } _logger.LogWarning("All processors failed to run {action}, returning null.", name); return null; } public class ProcessedImage : DriveFile.FileProperties { public string? Blurhash; public required IReadOnlyDictionary>?> RequestedFormats; public ProcessedImage(IImageInfo info) { Width = info.Width; Height = info.Height; } [SetsRequiredMembers] public ProcessedImage(IImageInfo info, Stream original, DriveFileCreationRequest request) : this(info) { var format = new ImageFormat.Keep(Path.GetExtension(request.Filename).TrimStart('.'), request.MimeType); RequestedFormats = new Dictionary>?> { [new ImageVersion(KeyEnum.Original, format)] = () => Task.FromResult(original) }; } } }