PlotDirector/PlotLine/Services/VisualIdentityImageService.cs

493 lines
22 KiB
C#

using System.Collections.Concurrent;
using PlotLine.Data;
using PlotLine.Models;
using SkiaSharp;
namespace PlotLine.Services;
public interface IVisualIdentityImageService
{
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption);
Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize);
Task RemoveCharacterImageAsync(Character character);
Task RemoveCharacterAvatarAsync(Character character);
Task DeleteCharacterGalleryImageAsync(CharacterImage image);
Task<VisualIdentityImageResult> 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<VisualIdentityImageService> logger) : IVisualIdentityImageService
{
private const long MaxUploadBytes = 10L * 1024L * 1024L;
private const int ThumbnailSize = 128;
private const int StandardMaxSize = 1200;
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
=> await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true);
public async Task<VisualIdentityImageResult> 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<VisualIdentityImageResult> 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<VisualIdentityImageResult> 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<VisualIdentityImageResult> UploadAsync(
string lockKey,
int projectId,
int entityId,
string? oldImagePath,
string? oldThumbnailPath,
string folderName,
string entityType,
Func<string?, string?, Task> 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<VisualIdentityImageResult> UploadCoreAsync(
int projectId,
int entityId,
string? oldImagePath,
string? oldThumbnailPath,
string folderName,
string entityType,
Func<string?, string?, Task> 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<VisualIdentityImageResult> 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<string?, string?, Task> 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));
}