PlotDirector/PlotLine/Services/IllustrationLibraryServices.cs

928 lines
50 KiB
C#

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
using SkiaSharp;
namespace PlotLine.Services;
public interface IIllustrationPromptBuilder
{
IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification);
}
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{
public const string CurrentTemplateVersion = "21D.1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification)
{
var errors = specification.Validate();
if (errors.Count > 0)
{
throw new ArgumentException(string.Join(" ", errors), nameof(specification));
}
var metadataJson = JsonSerializer.Serialize(specification.Metadata, JsonOptions);
var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette);
var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags);
var typedDetails = TypedDetails(specification);
var categoryRules = CategoryRules(specification.Category);
var prompt = $"""
Create a high-quality editorial illustration for PlotDirector's reusable Story Intelligence illustration library.
Category: {specification.Category}
Library code: {specification.Code}
Title: {specification.Title}
Subject: {specification.VisualSubject}
Structured details: {typedDetails}
Description: {specification.Description}
Composition: {specification.Composition}
Mood: {specification.Mood}
Palette guidance: {palette}
Metadata tags: {tags}
Category composition rules: {categoryRules}
Requirements:
- representative visual identifier only, not a canonical depiction of any manuscript
- editorial illustration, artistic sketch or painted concept art, stylised realism
- sophisticated and calm, suitable for a dark navy interface
- calm cinematic lighting and clear silhouette
- no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
- square composition that still reads clearly as a small thumbnail
""";
return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);
}
private static string CategoryRules(string category)
=> category switch
{
IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face large and readable; subdued background; suitable for circular cropping.",
IllustrationLibraryCategories.Location => "One clear focal environment; usable in circular and landscape crops; no prominent people; no text or readable signage.",
IllustrationLibraryCategories.Asset => "Square image; one dominant object; recognisable silhouette; clean subdued background; entire object visible where practical.",
_ => string.Empty
};
private static string TypedDetails(IllustrationGenerationSpecification specification)
=> specification.Category switch
{
IllustrationLibraryCategories.Character => string.Join("; ", new[]
{
$"apparent age band {specification.ApparentAgeBand}",
$"presentation {specification.Presentation}",
$"hair {specification.HairLength} {specification.HairColour}",
$"skin tone range {specification.SkinTone}",
$"features {string.Join(", ", specification.Features)}",
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
IllustrationLibraryCategories.Location => $"location type {specification.LocationType}; era {specification.Era}; setting {specification.Setting}",
IllustrationLibraryCategories.Asset => $"object type {specification.ObjectType}; colour {specification.Colour}; era {specification.Era}",
_ => string.Empty
};
}
public static class IllustrationStarterBatchDefinition
{
public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } =
[
Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with soft rim light", "watchful, resilient", ["teal shadows", "warm amber accent"]),
Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "portrait facing slightly away with alert eyes", "protective, uncertain", ["midnight blue", "copper", "muted rose"]),
Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, low directional light", "withheld, imposing", ["charcoal", "steel blue", "dull gold"]),
Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and a private sadness.", "soft portrait like a remembered photograph", "warm, elegiac", ["soft grey", "dusty green", "warm ivory"]),
Character("char-professional-investigator", "Professional investigator", "A composed adult investigator with notebook and steady focus.", "waist-up portrait, neutral background", "analytical, calm", ["navy", "cream", "oxide red"]),
Character("char-neighbourhood-friend", "Neighbourhood friend", "A friendly neighbour figure with practical clothes and open posture.", "casual portrait in corridor light", "helpful, cautious", ["sage", "denim", "soft yellow"]),
Character("char-family-relative", "Family relative", "A relative figure carrying old emotional context.", "portrait with subdued domestic background", "familial, layered", ["plum", "moss", "paper white"]),
Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "shadowed portrait with crisp edge light", "tense, unreadable", ["black", "deep green", "brass"]),
Character("char-young-helper", "Young helper", "A younger helper figure with attentive expression.", "bright side-lit portrait", "curious, supportive", ["sky blue", "warm grey", "coral"]),
Character("char-authorial-voice", "Recorded voice", "A character represented as memory and voice rather than direct presence.", "portrait dissolving into abstract sound-like light", "intimate, distant", ["indigo", "silver", "warm white"]),
Character("char-archival-contact", "Archival contact", "An older archive or records contact with patient authority.", "portrait with shelves suggested behind", "knowledgeable, reserved", ["umber", "slate", "muted gold"]),
Character("char-unknown-figure", "Unknown figure", "A partly obscured person used for unresolved identity.", "silhouette with soft facial detail, no menace caricature", "ambiguous, suspenseful", ["deep blue", "smoke grey", "amber"]),
Location("loc-estate-house-exterior", "Estate house exterior", "A rain-darkened old house or converted estate building.", "wide square view with lit windows and wet path", "quiet, secretive", ["blue black", "window gold", "wet stone"]),
Location("loc-small-flat-interior", "Small flat interior", "A modest lived-in flat with signs of careful searching.", "room corner with desk, lamp and disturbed papers", "intimate, investigative", ["olive", "warm paper", "deep blue"]),
Location("loc-laundry-utility-room", "Laundry utility room", "A low-lit utility or laundry room with domestic machinery.", "central washer, small window, practical clutter", "private, compressed", ["cool blue", "ceramic white", "rust accent"]),
Location("loc-narrow-stairwell", "Narrow stairwell", "A cramped stairwell or landing inside an older building.", "looking down the stairs with rail shadows", "pressured, transitional", ["slate", "old cream", "sodium amber"]),
Location("loc-wet-car-park", "Wet car park", "A small wet car park with one clear exit route.", "low view of wet tarmac and lights", "exposed, tense", ["asphalt", "red reflection", "cold blue"]),
Location("loc-domestic-desk", "Domestic desk", "A desk or table where clues are assembled.", "top-down three-quarter view of desk surface", "focused, revelatory", ["walnut", "paper white", "lamp gold"]),
Asset("asset-folded-letter", "Folded letter", "A folded handwritten-looking letter as generic evidence.", "single object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "sepia", "shadow blue"]),
Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on dark surface, blank pages", "investigative, intimate", ["brown leather", "cream paper", "blue shadow"]),
Asset("asset-red-classic-car", "Red classic car", "A small red classic sports car used as a reusable vehicle asset.", "three-quarter view with wet reflections, no badges or plates", "charged, mobile", ["deep red", "chrome grey", "night blue"]),
Asset("asset-old-keys", "Old keys", "A small bunch of old keys on a tag.", "close object study with strong silhouette", "access, possibility", ["brass", "graphite", "warm white"]),
Asset("asset-rucksack", "Rucksack", "A moved rucksack or soft bag.", "bag on floor near doorway", "recent movement, absence", ["canvas green", "brown", "cool shadow"]),
Asset("asset-phone-recorder", "Phone recording", "A phone or recorder playing an audio message.", "device glowing on table, no visible text", "intimate, urgent", ["black glass", "blue glow", "warm tabletop"]),
Asset("asset-torn-page", "Torn page", "A torn page fragment with no readable writing.", "paper fragment under lamplight", "partial evidence", ["paper white", "sepia", "ink blue"]),
Asset("asset-locked-box", "Locked box", "A small locked keepsake or document box.", "box with latch in soft light", "contained secret", ["dark wood", "brass", "cool grey"]),
Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail", "memory, proof", ["faded colour", "cream border", "shadow"]),
Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["muted green", "paper tan", "red accent"])
];
public static IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
public static IllustrationStarterGenerationPlan BuildGenerationPlan(IReadOnlyList<IllustrationLibraryItem> existingItems)
{
var byCode = existingItems
.Where(item => StarterCodes.Contains(item.StableCode))
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
var characters = CountEligible(IllustrationLibraryCategories.Character);
var locations = CountEligible(IllustrationLibraryCategories.Location);
var assets = CountEligible(IllustrationLibraryCategories.Asset);
return new(characters, locations, assets);
int CountEligible(string category)
=> All.Count(specification =>
{
if (!string.Equals(specification.Category, category, StringComparison.Ordinal))
{
return false;
}
if (!byCode.TryGetValue(specification.Code, out var existing))
{
return true;
}
if (existing.Any(IsSuccessfulOrRunning))
{
return false;
}
return existing.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed);
});
}
public static bool IsStarterCodeEligibleForGeneration(string code, IReadOnlyList<IllustrationLibraryItem> existingItems)
{
var matching = existingItems.Where(item => StarterCodes.Contains(item.StableCode) && string.Equals(item.StableCode, code, StringComparison.OrdinalIgnoreCase)).ToList();
return matching.Count == 0
|| (!matching.Any(IsSuccessfulOrRunning) && matching.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed));
}
private static bool IsSuccessfulOrRunning(IllustrationLibraryItem item)
=> item.Status is IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating;
private static IllustrationGenerationSpecification Character(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Character, code, title, subject, composition, mood, palette, ["character", "representative"]);
private static IllustrationGenerationSpecification Location(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Location, code, title, subject, composition, mood, palette, ["location", "representative"]);
private static IllustrationGenerationSpecification Asset(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Asset, code, title, subject, composition, mood, palette, ["asset", "representative"]);
private static IllustrationGenerationSpecification Spec(string category, string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette, IReadOnlyList<string> tags)
{
var shared = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence representative identifier",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism"
};
var common = new IllustrationGenerationSpecification
{
Category = category,
Code = code,
Title = title,
Description = "Representative visual identifier for Story Intelligence. Not canonical to any manuscript.",
VisualSubject = subject,
Composition = composition,
Mood = mood,
Palette = palette,
MetadataTags = tags,
Metadata = shared
};
return category switch
{
IllustrationLibraryCategories.Character => WithCharacter(common, code),
IllustrationLibraryCategories.Location => WithLocation(common, code),
IllustrationLibraryCategories.Asset => WithAsset(common, code),
_ => common
};
static IllustrationGenerationSpecification WithCharacter(IllustrationGenerationSpecification source, string code)
=> code switch
{
"char-young-adult-red-haired-witness" => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Red", hairLength: "Long", skinTone: "Light", features: ["Freckles"], clothingEra: "1980s", clothingStyle: "Casual"),
"char-middle-aged-weathered-man" => Copy(source, apparentAgeBand: "MiddleAged", presentation: "Masculine", hairColour: "DarkBrown", hairLength: "Short", skinTone: "LightMedium", features: ["LinedFace"], clothingEra: "1980s", clothingStyle: "SmartCasual"),
"char-older-soft-presence" => Copy(source, apparentAgeBand: "Older", presentation: "Feminine", hairColour: "Grey", hairLength: "Short", skinTone: "Light", features: ["GentleExpression"], clothingEra: "1980s", clothingStyle: "Domestic"),
"char-young-helper" => Copy(source, apparentAgeBand: "Teen", presentation: "Androgynous", hairColour: "Black", hairLength: "Short", skinTone: "Deep", features: ["BrightEyes"], clothingEra: "1980s", clothingStyle: "Casual"),
"char-archival-contact" => Copy(source, apparentAgeBand: "Older", presentation: "Masculine", hairColour: "White", hairLength: "Short", skinTone: "Medium", features: ["Glasses"], clothingEra: "1980s", clothingStyle: "Professional"),
_ => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Brown", hairLength: "Medium", skinTone: "Medium", features: ["ThoughtfulExpression"], clothingEra: "1980s", clothingStyle: "Casual")
};
static IllustrationGenerationSpecification WithLocation(IllustrationGenerationSpecification source, string code)
=> code switch
{
"loc-estate-house-exterior" => Copy(source, locationType: "DetachedHouse", era: "1980s", setting: "Suburban"),
"loc-small-flat-interior" => Copy(source, locationType: "ModestFlatInterior", era: "1980s", setting: "Urban"),
"loc-laundry-utility-room" => Copy(source, locationType: "LaundryUtilityRoom", era: "1980s", setting: "Domestic"),
"loc-wet-car-park" => Copy(source, locationType: "CarPark", era: "1980s", setting: "Urban"),
"loc-domestic-desk" => Copy(source, locationType: "Kitchen", era: "1980s", setting: "Domestic"),
_ => Copy(source, locationType: "UrbanStreet", era: "1980s", setting: "Urban")
};
static IllustrationGenerationSpecification WithAsset(IllustrationGenerationSpecification source, string code)
=> code switch
{
"asset-folded-letter" => Copy(source, objectType: "Letter", colour: "Cream", era: "1980s"),
"asset-worn-notebook" => Copy(source, objectType: "Notebook", colour: "Brown", era: "1980s"),
"asset-red-classic-car" => Copy(source, objectType: "ClassicSportsCar", colour: "Red", era: "1970s"),
"asset-old-keys" => Copy(source, objectType: "Keys", colour: "Brass", era: "1980s"),
"asset-rucksack" => Copy(source, objectType: "Rucksack", colour: "Green", era: "1980s"),
"asset-phone-recorder" => Copy(source, objectType: "Telephone", colour: "Black", era: "1980s"),
"asset-photo-print" => Copy(source, objectType: "Photograph", colour: "FadedColour", era: "1980s"),
"asset-locked-box" => Copy(source, objectType: "JewelleryBox", colour: "DarkWood", era: "1980s"),
"asset-torn-page" => Copy(source, objectType: "OfficialDocument", colour: "Cream", era: "1980s"),
_ => Copy(source, objectType: "Suitcase", colour: "Brown", era: "1980s")
};
static IllustrationGenerationSpecification Copy(
IllustrationGenerationSpecification source,
string? apparentAgeBand = null,
string? presentation = null,
string? hairColour = null,
string? hairLength = null,
string? skinTone = null,
IReadOnlyList<string>? features = null,
string? clothingEra = null,
string? clothingStyle = null,
string? locationType = null,
string? era = null,
string? setting = null,
string? objectType = null,
string? colour = null)
=> new()
{
Category = source.Category,
Code = source.Code,
Title = source.Title,
Description = source.Description,
VisualSubject = source.VisualSubject,
Composition = source.Composition,
Mood = source.Mood,
Palette = source.Palette,
MetadataTags = source.MetadataTags,
Metadata = source.Metadata,
ApparentAgeBand = apparentAgeBand,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
Features = features ?? [],
ClothingEra = clothingEra,
ClothingStyle = clothingStyle,
LocationType = locationType,
Era = era,
Setting = setting,
ObjectType = objectType,
Colour = colour
};
}
}
public interface IIllustrationImageProvider
{
string ProviderName { get; }
string EffectiveModel { get; }
bool IsConfigured { get; }
IReadOnlyList<string> MissingConfiguration { get; }
Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
}
public sealed class OpenAIIllustrationImageProvider(
HttpClient httpClient,
IOptions<StoryIntelligenceOptions> options,
ILogger<OpenAIIllustrationImageProvider> logger) : IIllustrationImageProvider
{
private readonly StoryIntelligenceOptions _options = options.Value;
public string ProviderName => "OpenAI";
public string EffectiveModel => string.IsNullOrWhiteSpace(_options.ImageGenerationModel) ? "not configured" : _options.ImageGenerationModel;
public bool IsConfigured => MissingConfiguration.Count == 0;
public IReadOnlyList<string> MissingConfiguration
{
get
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
missing.Add("OpenAI API key");
}
if (string.IsNullOrWhiteSpace(_options.ImageGenerationModel))
{
missing.Add("image-generation model");
}
return missing;
}
}
public async Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken)
{
if (!IsConfigured)
{
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"OpenAI image generation is not configured. Missing: {string.Join(", ", MissingConfiguration)}.");
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
httpRequest.Content = new StringContent(JsonSerializer.Serialize(new
{
model = _options.ImageGenerationModel,
prompt = request.Prompt,
size = string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize,
n = 1
}), Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(httpRequest, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = OpenAIImageError.FromResponseBody(body);
logger.LogWarning(
"OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
request.IllustrationLibraryItemID,
(int)response.StatusCode,
error.Code ?? "none",
error.Message);
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"Provider returned {(int)response.StatusCode}: {error.ForStorage()}");
}
using var document = JsonDocument.Parse(body);
var requestId = response.Headers.TryGetValues("x-request-id", out var values) ? values.FirstOrDefault() : null;
var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
if (string.IsNullOrWhiteSpace(b64))
{
return new(false, Provider: ProviderName, Model: EffectiveModel, ProviderRequestID: requestId, ErrorMessage: "Provider response did not include image data.");
}
return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId);
}
private static string TrimForStorage(string value)
=> value.Length <= 900 ? value : value[..900];
private sealed record OpenAIImageError(string Message, string? Code)
{
public static OpenAIImageError FromResponseBody(string body)
{
try
{
using var document = JsonDocument.Parse(body);
if (document.RootElement.TryGetProperty("error", out var error))
{
var message = error.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
var code = error.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
return new(TrimForStorage(string.IsNullOrWhiteSpace(message) ? body : message), string.IsNullOrWhiteSpace(code) ? null : TrimForStorage(code));
}
}
catch (JsonException)
{
}
return new(TrimForStorage(body), null);
}
public string ForStorage()
=> string.IsNullOrWhiteSpace(Code) ? Message : $"{Code}: {Message}";
}
}
public interface IIllustrationLibraryStorageService
{
IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes);
}
public sealed class IllustrationLibraryStorageService(IUploadStorageService uploadStorage) : IIllustrationLibraryStorageService
{
private const int StandardMaxSize = 1200;
private const int ThumbnailSize = 256;
public IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes)
{
using var bitmap = SKBitmap.Decode(imageBytes) ?? throw new InvalidDataException("Generated image could not be decoded.");
var safeCode = SafeSegment(code);
var categoryFolder = IllustrationLibraryCategories.StorageFolder(category);
var uploadRoot = uploadStorage.GetDirectory("story-intelligence-library", categoryFolder, safeCode);
var version = $"{itemId}-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
var imageFile = $"{version}.webp";
var thumbnailFile = $"{version}-thumb.webp";
var imagePath = Path.Combine(uploadRoot, imageFile);
var thumbnailPath = Path.Combine(uploadRoot, thumbnailFile);
SaveContainedWebp(bitmap, imagePath, StandardMaxSize, 84);
SaveCroppedWebp(bitmap, thumbnailPath, ThumbnailSize, ThumbnailSize, 78);
return new(
uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, imageFile),
uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, thumbnailFile),
bitmap.Width,
bitmap.Height,
new FileInfo(imagePath).Length,
"image/webp");
}
public static string SafeSegment(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var character in value.Trim().ToLowerInvariant())
{
if (char.IsLetterOrDigit(character) || character is '-')
{
builder.Append(character);
}
else if (char.IsWhiteSpace(character) || character is '_' or '.')
{
builder.Append('-');
}
}
var result = builder.ToString().Trim('-');
if (string.IsNullOrWhiteSpace(result) || result.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException("Illustration storage code is not safe.", nameof(value));
}
return result;
}
private static void SaveContainedWebp(SKBitmap bitmap, string path, int maxSize, int quality)
{
var scale = Math.Min(1f, maxSize / (float)Math.Max(bitmap.Width, bitmap.Height));
var targetWidth = Math.Max(1, (int)Math.Round(bitmap.Width * scale));
var targetHeight = Math.Max(1, (int)Math.Round(bitmap.Height * scale));
using var resized = bitmap.Resize(new SKImageInfo(targetWidth, targetHeight), SKSamplingOptions.Default) ?? bitmap.Copy();
using var image = SKImage.FromBitmap(resized);
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
data.SaveTo(stream);
}
private static void SaveCroppedWebp(SKBitmap bitmap, string path, int targetWidth, int targetHeight, int quality)
{
var sourceSize = Math.Min(bitmap.Width, bitmap.Height);
var left = (bitmap.Width - sourceSize) / 2;
var top = (bitmap.Height - sourceSize) / 2;
using var surface = SKSurface.Create(new SKImageInfo(targetWidth, targetHeight));
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
canvas.DrawBitmap(
bitmap,
new SKRect(left, top, left + sourceSize, top + sourceSize),
new SKRect(0, 0, targetWidth, targetHeight));
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
data.SaveTo(stream);
}
}
public interface IIllustrationGenerationRunner
{
Task ProcessNextAsync(CancellationToken cancellationToken);
}
public sealed class IllustrationGenerationRunner(
IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider,
IIllustrationLibraryStorageService storage,
ILogger<IllustrationGenerationRunner> logger) : IIllustrationGenerationRunner
{
public async Task ProcessNextAsync(CancellationToken cancellationToken)
{
var item = await repository.ClaimNextAsync();
if (item is null)
{
return;
}
try
{
var specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(
item.SpecificationJson ?? string.Empty,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (specification is null)
{
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, "Generation specification could not be read.");
return;
}
var prompt = promptBuilder.Build(specification);
var request = new IllustrationImageGenerationRequest(item.IllustrationLibraryItemID, item.StableCode, item.Category, prompt.Prompt, prompt.TemplateVersion);
var result = await provider.GenerateAsync(request, cancellationToken);
if (!result.Succeeded || !result.HasImage)
{
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, result.ErrorMessage ?? "Provider did not return an image.");
return;
}
var stored = storage.StoreGeneratedImage(item.IllustrationLibraryItemID, item.Category, item.StableCode, result.ImageBytes!);
await repository.MarkGeneratedAsync(item.IllustrationLibraryItemID, stored, result, prompt.Prompt, prompt.TemplateVersion);
}
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or ArgumentException)
{
logger.LogWarning(ex, "Illustration generation failed for library item {ItemID}.", item.IllustrationLibraryItemID);
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, ex.Message);
}
}
}
public sealed class IllustrationGenerationWorker(
IServiceScopeFactory scopeFactory,
ILogger<IllustrationGenerationWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IIllustrationGenerationRunner>();
await runner.ProcessNextAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
logger.LogError(ex, "Illustration generation worker failed while checking the queue.");
}
await timer.WaitForNextTickAsync(stoppingToken);
}
}
}
public interface IIllustrationLibraryService
{
Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter);
Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync();
Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed);
Task<IllustrationBulkRetryResult> RequeueAllFailedAsync();
Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed);
Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId);
Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason);
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
}
public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider) : IIllustrationLibraryService
{
private const int MaxGenerationAttempts = 3;
public async Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter)
{
await repository.SupersedeRecoveredFailuresAsync();
var items = await repository.ListAsync(filter);
var allItems = string.IsNullOrWhiteSpace(filter.Category) && string.IsNullOrWhiteSpace(filter.Status) && string.IsNullOrWhiteSpace(filter.Search)
? items
: await repository.ListAsync(new IllustrationLibraryFilter());
var summary = await repository.GetSummaryAsync();
var missingConfiguration = provider.MissingConfiguration;
return new()
{
Filter = filter,
Items = items,
Summary = summary,
StarterCharacterCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character),
StarterLocationCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location),
StarterAssetCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset),
StarterGenerationPlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(allItems),
ProviderStatus = provider.IsConfigured
? $"Configured: {provider.ProviderName} {provider.EffectiveModel}"
: $"Image generation is not configured. Missing: {string.Join(", ", missingConfiguration)}."
};
}
public async Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync()
{
var createdOrExisting = 0;
foreach (var specification in IllustrationStarterBatchDefinition.All)
{
var prompt = promptBuilder.Build(specification);
await repository.UpsertPlannedAsync(specification, prompt);
createdOrExisting++;
}
return new(true, $"Starter plan is ready with {createdOrExisting:N0} reusable illustration specifications.");
}
public async Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed)
{
var beforeItems = await repository.ListAsync(new IllustrationLibraryFilter());
var beforePlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(beforeItems);
if (!confirmed)
{
return new(false, $"Confirm Generate Starter Library first. This will queue {beforePlan.CharactersToGenerate:N0} characters, {beforePlan.LocationsToGenerate:N0} locations and {beforePlan.AssetsToGenerate:N0} assets ({beforePlan.TotalToGenerate:N0} total).");
}
await EnsureStarterPlanAsync();
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var starterItems = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed)
.Where(item => IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(item.StableCode, items))
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(item => item.Status == IllustrationLibraryStatuses.Failed ? 0 : 1).ThenByDescending(item => item.UpdatedUtc).First())
.ToList();
var queued = 0;
foreach (var item in starterItems)
{
queued += await repository.QueueAsync(item.IllustrationLibraryItemID, null);
}
var queuedCharacters = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Character);
var queuedLocations = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Location);
var queuedAssets = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Asset);
return new(
true,
queued == 0
? "Starter Library is already generated, queued or in progress. No new images were queued."
: $"Starter Library generation started: {queuedCharacters:N0} characters, {queuedLocations:N0} locations and {queuedAssets:N0} assets queued ({queued:N0} total).");
}
public async Task<IllustrationBulkRetryResult> RequeueAllFailedAsync()
{
await repository.SupersedeRecoveredFailuresAsync();
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var failed = items
.Where(item => item.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
.ToList();
var retryLimitReached = failed.Count(item => item.GenerationAttemptCount >= MaxGenerationAttempts);
var queued = 0;
var skipped = 0;
foreach (var item in failed.Where(item => item.GenerationAttemptCount < MaxGenerationAttempts))
{
var queuedNow = await QueueRetryTargetAsync(item, items);
queued += queuedNow;
if (queuedNow == 0 && ResolveRetryTarget(item, items) is null)
{
skipped++;
}
}
return new(queued, skipped, retryLimitReached);
}
public static IllustrationLibraryItem? ResolveBulkRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
=> ResolveRetryTarget(failedItem, items);
private static IllustrationLibraryItem? ResolveRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
{
var child = items
.Where(candidate =>
candidate.ParentItemID == failedItem.IllustrationLibraryItemID
&& candidate.IsActive
&& candidate.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved)
.OrderByDescending(candidate => candidate.UpdatedUtc)
.ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
.FirstOrDefault();
if (child is not null)
{
return child.Status == IllustrationLibraryStatuses.Planned ? child : null;
}
if (failedItem.Status == IllustrationLibraryStatuses.Superseded)
{
return failedItem;
}
var siblings = items
.Where(candidate =>
candidate.IllustrationLibraryItemID != failedItem.IllustrationLibraryItemID
&& candidate.IsActive
&& string.Equals(candidate.StableCode, failedItem.StableCode, StringComparison.OrdinalIgnoreCase))
.ToList();
if (siblings.Any(candidate => candidate.Status is IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved))
{
return null;
}
return siblings
.Where(candidate => candidate.Status == IllustrationLibraryStatuses.Planned)
.OrderByDescending(candidate => candidate.UpdatedUtc)
.ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
.FirstOrDefault()
?? failedItem;
}
private async Task<int> QueueRetryTargetAsync(IllustrationLibraryItem source, IReadOnlyList<IllustrationLibraryItem> items)
{
var target = ResolveRetryTarget(source, items);
if (target is null)
{
return 0;
}
if (target.IllustrationLibraryItemID == source.IllustrationLibraryItemID
&& source.Status == IllustrationLibraryStatuses.Superseded)
{
var regeneration = await repository.CreateRegenerationAsync(source.IllustrationLibraryItemID, "Created from bulk failed-image retry action.");
return regeneration is null ? 0 : await repository.QueueAsync(regeneration.IllustrationLibraryItemID, null);
}
return await repository.QueueAsync(target.IllustrationLibraryItemID, null);
}
public static bool HasActiveBlockingSibling(IllustrationLibraryItem item, IReadOnlyList<IllustrationLibraryItem> items)
=> items.Any(candidate =>
candidate.IllustrationLibraryItemID != item.IllustrationLibraryItemID
&& candidate.IsActive
&& string.Equals(candidate.StableCode, item.StableCode, StringComparison.OrdinalIgnoreCase)
&& candidate.Status is IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved);
public async Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed)
{
if (!confirmed)
{
return new(false, "Confirm this queued generation action first. This can create provider cost once image generation is configured.");
}
if (!string.IsNullOrWhiteSpace(category) && !IllustrationLibraryCategories.IsValid(category))
{
return new(false, "Unknown illustration category.");
}
if (illustrationLibraryItemId.HasValue)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var requested = items.FirstOrDefault(item => item.IllustrationLibraryItemID == illustrationLibraryItemId.Value);
if (requested?.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
{
var target = ResolveRetryTarget(requested, items);
if (target is null)
{
return new(true, "This illustration already has a regeneration generated, queued or in progress.");
}
if (target.IllustrationLibraryItemID == requested.IllustrationLibraryItemID
&& requested.Status == IllustrationLibraryStatuses.Superseded)
{
var regeneration = await repository.CreateRegenerationAsync(requested.IllustrationLibraryItemID, "Created from admin queue action.");
if (regeneration is null)
{
return new(false, "This illustration is not eligible for regeneration.");
}
illustrationLibraryItemId = regeneration.IllustrationLibraryItemID;
}
else
{
illustrationLibraryItemId = target.IllustrationLibraryItemID;
}
}
}
var affected = await repository.QueueAsync(illustrationLibraryItemId, category);
return new(true, affected == 0 ? "No eligible planned items were queued." : $"{affected:N0} illustration item(s) queued.");
}
public async Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId)
{
await repository.ApproveAsync(illustrationLibraryItemId, userId);
return new(true, "Illustration approved for library use.");
}
public async Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason)
{
await repository.RejectAsync(illustrationLibraryItemId, userId, reason);
return new(true, "Illustration rejected. It can be regenerated later.");
}
public async Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed)
{
if (!confirmed)
{
return new(false, "Confirm regeneration first. This creates a new planned child item and can create provider cost once queued.");
}
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var existing = items
.Where(item =>
item.ParentItemID == illustrationLibraryItemId
&& item.IsActive
&& item.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved)
.OrderByDescending(item => item.UpdatedUtc)
.ThenByDescending(item => item.IllustrationLibraryItemID)
.FirstOrDefault();
if (existing is not null)
{
if (existing.Status == IllustrationLibraryStatuses.Planned)
{
var queuedExisting = await repository.QueueAsync(existing.IllustrationLibraryItemID, null);
return queuedExisting == 0
? new(true, "This regeneration is already queued or in progress.")
: new(true, $"Existing regeneration item {existing.IllustrationLibraryItemID:N0} queued.");
}
return new(true, "This illustration already has a regeneration generated, queued or in progress.");
}
var item = await repository.CreateRegenerationAsync(illustrationLibraryItemId, "Created from admin regeneration action.");
if (item is null)
{
return new(false, "This illustration is not eligible for regeneration.");
}
var queued = await repository.QueueAsync(item.IllustrationLibraryItemID, null);
return queued == 0
? new(false, "Regeneration was created but could not be queued.")
: new(true, $"Regeneration item {item.IllustrationLibraryItemID:N0} created and queued.");
}
public async Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId)
{
await repository.DisableAsync(illustrationLibraryItemId);
return new(true, "Illustration disabled.");
}
public async Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form)
{
if (string.IsNullOrWhiteSpace(form.DisplayTitle))
{
return new(false, "Title is required.");
}
await repository.UpdateMetadataAsync(illustrationLibraryItemId, form.DisplayTitle, form.ShortDescription, form.MetadataJson, form.AdminNotes);
return new(true, "Illustration metadata updated.");
}
public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.FilePath is not null && item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().FilePath!, StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
if (!string.IsNullOrWhiteSpace(scene.Location.LibraryCode) && lookup.TryGetValue(scene.Location.LibraryCode, out var locationPath))
{
scene.Location.ImagePath = locationPath;
}
foreach (var character in scene.Characters)
{
if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath))
{
character.ImagePath = characterPath;
}
}
foreach (var asset in scene.Assets)
{
if (!string.IsNullOrWhiteSpace(asset.LibraryCode) && lookup.TryGetValue(asset.LibraryCode, out var assetPath))
{
asset.ImagePath = assetPath;
}
}
}
}
}