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 UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption); Task CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize); Task RemoveCharacterImageAsync(Character character); Task RemoveCharacterAvatarAsync(Character character); Task DeleteCharacterGalleryImageAsync(CharacterImage image); Task UploadAssetImageAsync(StoryAsset asset, IFormFile upload); Task RemoveAssetImageAsync(StoryAsset asset); } public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null, int? CharacterImageID = 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 async Task UploadCharacterImageAsync(Character character, IFormFile upload) => await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true); public async Task UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption) => await UploadCharacterGalleryImageCoreAsync(character, upload, caption, updateLegacyImage: false); public Task RemoveCharacterImageAsync(Character character) => RemoveAsync( character.ImagePath, character.ThumbnailPath, "uploads/characters", (imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath)); public async Task RemoveCharacterAvatarAsync(Character character) { await characters.UpdateCharacterAvatarAsync(character.CharacterID, null, null, null); await MarkPreviousLedgerRowsAsync(character.AvatarImagePath); await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath); DeletePreviousPhysicalFile(character.AvatarImagePath, null, "uploads/characters"); DeletePreviousPhysicalFile(character.AvatarThumbnailPath, null, "uploads/characters"); } public async Task DeleteCharacterGalleryImageAsync(CharacterImage image) { await characters.DeleteCharacterImageAsync(image.CharacterImageID); await MarkPreviousLedgerRowsAsync(image.ImagePath); await MarkPreviousLedgerRowsAsync(image.ThumbnailPath); DeletePreviousPhysicalFile(image.ImagePath, null, "uploads/characters"); DeletePreviousPhysicalFile(image.ThumbnailPath, null, "uploads/characters"); } public async Task CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize) { if (sourceImage.CharacterID != character.CharacterID) { return new(false, "Choose an image from this character's gallery."); } var sourcePath = uploadStorage.TryResolvePublicPath(sourceImage.ImagePath, "uploads/characters"); if (sourcePath is null || !File.Exists(sourcePath)) { return new(false, "The source image could not be found."); } var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID); if (!ownerUserId.HasValue) { return new(false, "Project owner could not be found."); } var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, 256 * 256); if (!storageLimit.Allowed) { return new(false, storageLimit.Message); } try { using var bitmap = SKBitmap.Decode(sourcePath) ?? throw new InvalidDataException(); var size = cropSize <= 0 ? Math.Min(bitmap.Width, bitmap.Height) : cropSize; size = Math.Min(size, Math.Min(bitmap.Width, bitmap.Height)); cropX = Math.Clamp(cropX, 0, Math.Max(0, bitmap.Width - size)); cropY = Math.Clamp(cropY, 0, Math.Max(0, bitmap.Height - size)); var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString()); var version = Guid.NewGuid().ToString("N"); var avatarTemp = Path.Combine(uploadRoot, $"avatar-{version}.tmp"); var avatarPath = Path.Combine(uploadRoot, "avatar.webp"); var avatarStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "avatar.webp"); SaveWebp(bitmap, avatarTemp, new SKRect(cropX, cropY, cropX + size, cropY + size), 256, 256, 82); ReplaceFile(avatarTemp, avatarPath); await characters.UpdateCharacterAvatarAsync(character.CharacterID, sourceImage.CharacterImageID, avatarStoragePath, avatarStoragePath); await MarkPreviousLedgerRowsAsync(character.AvatarImagePath); await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath); await TrackImageAsync(ownerUserId.Value, character.ProjectID, character.CharacterID, "CharacterAvatar", avatarStoragePath, "Character avatar", new FileInfo(avatarPath).Length); DeletePreviousPhysicalFile(character.AvatarImagePath, avatarStoragePath, "uploads/characters"); DeletePreviousPhysicalFile(character.AvatarThumbnailPath, avatarStoragePath, "uploads/characters"); return new(true); } catch (Exception ex) when (ex is InvalidDataException or IOException or ArgumentException) { logger.LogWarning(ex, "Character avatar crop failed for Character {CharacterID}.", character.CharacterID); return new(false, "Avatar could not be generated. Try another crop or source image."); } } 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 UploadCharacterGalleryImageCoreAsync(Character character, IFormFile upload, string? caption, bool updateLegacyImage) { var uploadLock = EntityLocks.GetOrAdd($"character-gallery:{character.CharacterID}", _ => new SemaphoreSlim(1, 1)); await uploadLock.WaitAsync(); try { 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(character.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 version = Guid.NewGuid().ToString("N"); var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString(), "gallery", version); var imageTemp = Path.Combine(uploadRoot, "image.tmp"); var thumbnailTemp = Path.Combine(uploadRoot, "thumb.tmp"); var imagePath = Path.Combine(uploadRoot, "image.webp"); var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp"); var imageStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "image.webp"); var thumbnailStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "thumb.webp"); try { SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82); SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76); ReplaceFile(imageTemp, imagePath); ReplaceFile(thumbnailTemp, thumbnailPath); var characterImageId = await characters.CreateCharacterImageAsync(new CharacterImage { CharacterID = character.CharacterID, ProjectID = character.ProjectID, ImagePath = imageStoragePath, ThumbnailPath = thumbnailStoragePath, Caption = caption }); if (updateLegacyImage || string.IsNullOrWhiteSpace(character.ImagePath)) { await characters.UpdateCharacterImageAsync(character.CharacterID, imageStoragePath, thumbnailStoragePath); } await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", imageStoragePath, upload.FileName, new FileInfo(imagePath).Length); await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length); return new(true, CharacterImageID: characterImageId); } catch (Exception ex) { TryDeleteFile(imageTemp); TryDeleteFile(thumbnailTemp); logger.LogWarning(ex, "Character gallery image upload failed for Character {CharacterID}.", character.CharacterID); return new(false, "Image could not be processed. Try another image."); } } } finally { uploadLock.Release(); } } 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)); }