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.2"; 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 - modern cinematic digital illustration with premium narrative game concept art quality - vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions - bright balanced lighting, clear facial illumination where faces are present, and strong local contrast - crisp subject definition with strong foreground-background separation and clean readable silhouettes - natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate - polished promotional artwork for a premium story-driven adventure game, not a literal photograph - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface - 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 - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results - 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-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.", IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.", IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; 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 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 bright rim light and clear facial detail", "watchful, resilient", ["rich teal", "warm amber", "clear skin tones"]), Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "face-readable portrait with alert eyes and clean background separation", "protective, uncertain", ["deep blue", "copper", "vivid rose"]), Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, bright directional key light", "withheld, imposing", ["graphite", "steel blue", "bright brass"]), Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and private warmth.", "clean bright portrait with soft contemporary depth of field", "warm, reflective", ["pearl grey", "fresh 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 clean domestic background separation", "familial, layered", ["plum", "fresh moss", "paper white"]), Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "bright-edged portrait with visible facial structure and crisp outline", "tense, unreadable", ["ink 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 and a bright readable face", "knowledgeable, reserved", ["warm sienna", "slate blue", "bright 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", "An old house or converted estate building with an intriguing entrance.", "wide square view with readable architecture, glowing windows and clear path", "quiet, secretive", ["clear navy", "window gold", "wet stone highlights"]), 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, clean readable light", "intimate, investigative", ["fresh green", "warm paper", "clear blue"]), Location("loc-laundry-utility-room", "Laundry utility room", "A utility or laundry room with domestic machinery.", "central washer, small window, practical clutter, bright subject separation", "private, compressed", ["clean 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 with vivid reflections and bright readable lighting", "exposed, tense", ["asphalt grey", "red reflection", "clear 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 bright object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "warm gold", "clear blue"]), Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on clean contrasting surface, blank pages", "investigative, intimate", ["rich leather", "cream paper", "clear blue"]), 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 with crisp outline", "recent movement, absence", ["canvas green", "warm tan", "clean blue"]), 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 bright warm lamplight", "partial evidence", ["paper white", "warm gold", "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 and bright edge separation", "memory, proof", ["vintage colour", "cream border", "clean graphite"]), 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", ["fresh green", "paper tan", "red accent"]) ]; public static IReadOnlySet StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); public static IllustrationStarterGenerationPlan BuildGenerationPlan(IReadOnlyList 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 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 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 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 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 palette, IReadOnlyList tags) { var shared = new Dictionary(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? 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 MissingConfiguration { get; } Task GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken); } public sealed class OpenAIIllustrationImageProvider( HttpClient httpClient, IOptions options, ILogger 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 MissingConfiguration { get { var missing = new List(); 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 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 logger) : IIllustrationGenerationRunner { public async Task ProcessNextAsync(CancellationToken cancellationToken) { var item = await repository.ClaimNextAsync(); if (item is null) { return; } try { var specification = JsonSerializer.Deserialize( 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 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(); 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 BuildAdminViewModelAsync(IllustrationLibraryFilter filter); Task EnsureStarterPlanAsync(); Task GenerateStarterLibraryAsync(bool confirmed); Task RequeueAllFailedAsync(); Task QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed); Task ApproveAsync(int illustrationLibraryItemId, int userId); Task RejectAsync(int illustrationLibraryItemId, int userId, string? reason); Task RegenerateAsync(int illustrationLibraryItemId, bool confirmed); Task DisableAsync(int illustrationLibraryItemId); Task UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form); Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype); } public sealed class IllustrationLibraryService( IIllustrationLibraryRepository repository, IIllustrationPromptBuilder promptBuilder, IIllustrationImageProvider provider, IUploadStorageService uploadStorage) : IIllustrationLibraryService { private const int MaxGenerationAttempts = 3; public async Task 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 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 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 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 items) => ResolveRetryTarget(failedItem, items); private static IllustrationLibraryItem? ResolveRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList 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 QueueRetryTargetAsync(IllustrationLibraryItem source, IReadOnlyList 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 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 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 ApproveAsync(int illustrationLibraryItemId, int userId) { await repository.ApproveAsync(illustrationLibraryItemId, userId); return new(true, "Illustration approved for library use."); } public async Task 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 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 DisableAsync(int illustrationLibraryItemId) { await repository.DisableAsync(illustrationLibraryItemId); return new(true, "Illustration disabled."); } public async Task 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.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.ToList(), StringComparer.OrdinalIgnoreCase); foreach (var scene in prototype.Scenes) { var locationFallback = scene.Location.ImagePath; var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup); scene.Location.ImageResolution = locationResolution; scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback; foreach (var character in scene.Characters) { var fallback = character.ImagePath; var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup); character.ImageResolution = resolution; character.ImagePath = resolution.FinalImageUrl ?? fallback; } foreach (var asset in scene.Assets) { var fallback = asset.ImagePath; var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup); asset.ImageResolution = resolution; asset.ImagePath = resolution.FinalImageUrl ?? fallback; } } } private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage( string entityId, string stableCode, string fallbackImageUrl, IReadOnlyDictionary> lookup) { if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates)) { return PrototypeFallback(entityId, stableCode, fallbackImageUrl); } foreach (var candidate in candidates) { var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); if (publicUrl is null) { continue; } return new() { EntityId = entityId, StableCode = stableCode, IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, Status = candidate.Status, FinalImageUrl = publicUrl, FallbackImageUrl = fallbackImageUrl, FallbackUsed = false }; } var firstCandidate = candidates.FirstOrDefault(); return new() { EntityId = entityId, StableCode = stableCode, IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID, Status = firstCandidate?.Status, FallbackImageUrl = fallbackImageUrl, FallbackUsed = true }; } private string? ResolveExistingPublicUploadUrl(string? storedPath) { var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); if (physicalPath is null || !File.Exists(physicalPath)) { return null; } var normalised = storedPath!.Replace('\\', '/').Trim(); if (!normalised.StartsWith('/')) { normalised = "/" + normalised.TrimStart('/'); } return EncodeUrlPath(normalised); } private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl) => new() { EntityId = entityId, StableCode = stableCode, FallbackImageUrl = fallbackImageUrl, FallbackUsed = true }; private static string EncodeUrlPath(string path) => string.Join( '/', path.Split('/', StringSplitOptions.None) .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment))); }