using System.Collections.Concurrent; using PlotLine.Data; using PlotLine.Models; using SkiaSharp; namespace PlotLine.Services; public interface IVisualIdentityImageService { Task UploadCharacterImageAsync(Character character, IFormFile upload); Task RemoveCharacterImageAsync(Character character); Task UploadAssetImageAsync(StoryAsset asset, IFormFile upload); Task RemoveAssetImageAsync(StoryAsset asset); } public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null); public sealed class VisualIdentityImageService( IUploadStorageService uploadStorage, IProjectCollaborationRepository collaboration, ISubscriptionService subscriptions, IUserFileRepository userFiles, ICharacterRepository characters, IAssetRepository assets, ILogger logger) : IVisualIdentityImageService { private const long MaxUploadBytes = 10L * 1024L * 1024L; private const int ThumbnailSize = 128; private const int StandardMaxSize = 1200; private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; private static readonly ConcurrentDictionary EntityLocks = new(); public Task UploadCharacterImageAsync(Character character, IFormFile upload) => UploadAsync( $"character:{character.CharacterID}", character.ProjectID, character.CharacterID, character.ImagePath, character.ThumbnailPath, "characters", "CharacterImage", (imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath), upload); public Task RemoveCharacterImageAsync(Character character) => RemoveAsync( character.ImagePath, character.ThumbnailPath, "uploads/characters", (imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath)); public Task UploadAssetImageAsync(StoryAsset asset, IFormFile upload) => UploadAsync( $"asset:{asset.StoryAssetID}", asset.ProjectID, asset.StoryAssetID, asset.ImagePath, asset.ThumbnailPath, "story-assets", "StoryAssetImage", (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath), upload); public Task RemoveAssetImageAsync(StoryAsset asset) => RemoveAsync( asset.ImagePath, asset.ThumbnailPath, "uploads/story-assets", (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath)); private async Task UploadAsync( string lockKey, int projectId, int entityId, string? oldImagePath, string? oldThumbnailPath, string folderName, string entityType, Func updateImage, IFormFile upload) { var uploadLock = EntityLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); await uploadLock.WaitAsync(); try { return await UploadCoreAsync(projectId, entityId, oldImagePath, oldThumbnailPath, folderName, entityType, updateImage, upload); } finally { uploadLock.Release(); } } private async Task UploadCoreAsync( int projectId, int entityId, string? oldImagePath, string? oldThumbnailPath, string folderName, string entityType, Func updateImage, IFormFile upload) { if (upload.Length <= 0) { return new(false, "Choose an image to upload."); } if (upload.Length > MaxUploadBytes) { return new(false, "Image must be 10 MB or smaller."); } if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName))) { return new(false, "Image must be a JPG, PNG or WebP file."); } var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId); if (!ownerUserId.HasValue) { return new(false, "Project owner could not be found."); } var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length)); if (!storageLimit.Allowed) { return new(false, storageLimit.Message); } SKBitmap bitmap; try { await using var uploadStream = upload.OpenReadStream(); using var memory = new MemoryStream(); await uploadStream.CopyToAsync(memory); memory.Position = 0; using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException(); if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp)) { return new(false, "Image must be a valid JPG, PNG or WebP file."); } memory.Position = 0; bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException(); } catch (Exception ex) when (ex is InvalidDataException or ArgumentException) { return new(false, "Image must be a valid JPG, PNG or WebP file."); } using (bitmap) { if (bitmap.Width <= 0 || bitmap.Height <= 0) { return new(false, "Image could not be read as a valid image."); } var uploadRoot = uploadStorage.GetDirectory(folderName, entityId.ToString()); var version = Guid.NewGuid().ToString("N"); var imageTemp = Path.Combine(uploadRoot, $"image-{version}.tmp"); var thumbnailTemp = Path.Combine(uploadRoot, $"thumb-{version}.tmp"); var imagePath = Path.Combine(uploadRoot, "image.webp"); var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp"); var imageStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "image.webp"); var thumbnailStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "thumb.webp"); try { SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82); SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76); ReplaceFile(imageTemp, imagePath); ReplaceFile(thumbnailTemp, thumbnailPath); await updateImage(imageStoragePath, thumbnailStoragePath); await MarkPreviousLedgerRowsAsync(oldImagePath); await MarkPreviousLedgerRowsAsync(oldThumbnailPath); await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, imageStoragePath, upload.FileName, new FileInfo(imagePath).Length); await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length); DeletePreviousPhysicalFile(oldImagePath, imageStoragePath, $"uploads/{folderName}"); DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath, $"uploads/{folderName}"); return new(true); } catch (Exception ex) { TryDeleteFile(imageTemp); TryDeleteFile(thumbnailTemp); logger.LogWarning(ex, "Visual identity image upload failed for {EntityType} {EntityID}.", entityType, entityId); return new(false, "Image could not be processed. Try another image."); } } } private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func updateImage) { await updateImage(null, null); await MarkPreviousLedgerRowsAsync(imagePath); await MarkPreviousLedgerRowsAsync(thumbnailPath); DeletePreviousPhysicalFile(imagePath, null, requiredPrefix); DeletePreviousPhysicalFile(thumbnailPath, null, requiredPrefix); } private static void SaveContainedWebp(SKBitmap source, string path, int maxSize, int quality) { var scale = Math.Min(maxSize / (float)source.Width, maxSize / (float)source.Height); scale = Math.Min(scale, 1f); var width = Math.Max(1, (int)Math.Round(source.Width * scale)); var height = Math.Max(1, (int)Math.Round(source.Height * scale)); SaveWebp(source, path, new SKRect(0, 0, source.Width, source.Height), width, height, quality); } private static void SaveCroppedWebp(SKBitmap source, string path, int width, int height, int quality) { var sourceRatio = source.Width / (float)source.Height; var targetRatio = width / (float)height; SKRect sourceRect; if (sourceRatio > targetRatio) { var cropWidth = source.Height * targetRatio; var left = (source.Width - cropWidth) / 2f; sourceRect = new SKRect(left, 0, left + cropWidth, source.Height); } else { var cropHeight = source.Width / targetRatio; var top = (source.Height - cropHeight) / 2f; sourceRect = new SKRect(0, top, source.Width, top + cropHeight); } SaveWebp(source, path, sourceRect, width, height, quality); } private static void SaveWebp(SKBitmap source, string path, SKRect sourceRect, int width, int height, int quality) { using var surface = SKSurface.Create(new SKImageInfo(width, height)); var canvas = surface.Canvas; canvas.Clear(SKColors.Transparent); using var paint = new SKPaint { IsAntialias = true }; using var sourceImage = SKImage.FromBitmap(source); canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint); canvas.Flush(); using var image = surface.Snapshot(); using var data = image.Encode(SKEncodedImageFormat.Webp, quality); using var output = File.Create(path); data.SaveTo(output); } private async Task TrackImageAsync(int ownerUserId, int projectId, int entityId, string entityType, string storagePath, string originalFileName, long fileSizeBytes) { await userFiles.CreateAsync(new UserFile { UserID = ownerUserId, ProjectID = projectId, EntityType = entityType, EntityID = entityId, FileName = Path.GetFileName(storagePath), OriginalFileName = originalFileName, ContentType = "image/webp", FileSizeBytes = fileSizeBytes, StoragePath = storagePath }); } private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath) { if (!string.IsNullOrWhiteSpace(oldStoragePath)) { await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath); } } private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath, string requiredPrefix) { if (string.IsNullOrWhiteSpace(oldStoragePath) || string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase)) { return; } uploadStorage.TryDeleteFile(oldStoragePath, requiredPrefix); } private static void ReplaceFile(string tempPath, string finalPath) { if (File.Exists(finalPath)) { File.Delete(finalPath); } File.Move(tempPath, finalPath); } private static void TryDeleteFile(string path) { if (File.Exists(path)) { File.Delete(path); } } private static long EstimatedGeneratedSize(long uploadBytes) => Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes)); }