PlotDirector/PlotLine/Services/VisualIdentityImageService.cs
Nick Beckley 34a8d94cb4 Implemented the Visual Identity infrastructure pass.
Changed
Added image fields for Characters and Story Assets in models, view models, repositories, services, and save flows.
Added reusable avatar/initials rendering via [AvatarHelper.cs](C:/Source/PlotLine/PlotLine/Services/AvatarHelper.cs) and [_Avatar.cshtml](C:/Source/PlotLine/PlotLine/Views/Shared/_Avatar.cshtml).
Added upload/remove processing through existing upload storage in [VisualIdentityImageService.cs](C:/Source/PlotLine/PlotLine/Services/VisualIdentityImageService.cs).
Updated Character and Story Asset list/detail/edit views with thumbnails, initials fallback, upload, replace, and remove controls.
Added styling in [site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css) and refreshed site.min.css.
Database
Added new migration [079_VisualIdentityCharacterAssetImages.sql](C:/Source/PlotLine/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql).
Adds nullable ImagePath and ThumbnailPath to Characters and StoryAssets.
Applied locally and confirmed the columns exist.
Stored Procedures
Updated active procedure definitions only:
Character_ListByProject
Character_Get
Character_Save
Character_UpdateImage
StoryAsset_ListByProject
StoryAsset_Get
StoryAsset_Save
StoryAsset_UpdateImage
2026-06-22 10:10:39 +01:00

308 lines
12 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 RemoveCharacterImageAsync(Character character);
Task<VisualIdentityImageResult> 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<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 Task<VisualIdentityImageResult> 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<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 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));
}