493 lines
22 KiB
C#
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));
|
|
}
|