PlotDirector/PlotLine/Services/IllustrationLibraryServices.cs

1036 lines
57 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 = "21J.3";
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 metadata = new Dictionary<string, string>(specification.Metadata, StringComparer.OrdinalIgnoreCase);
AddMetadata("category", specification.Category);
AddMetadata("metadataConfidence", "High");
if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
{
AddMetadata("ageBand", specification.ApparentAgeBand);
AddMetadata("presentation", specification.Presentation);
AddMetadata("hairColour", specification.HairColour);
AddMetadata("skinTone", specification.SkinTone);
AddMetadata("hairLength", specification.HairLength);
AddMetadata("glasses", specification.Features.Any(feature => feature.Contains("glasses", StringComparison.OrdinalIgnoreCase)) ? "Yes" : "Unknown");
AddMetadata("facialHair", specification.Features.FirstOrDefault(feature => feature.Contains("beard", StringComparison.OrdinalIgnoreCase) || feature.Contains("moustache", StringComparison.OrdinalIgnoreCase)));
AddMetadata("clothingEra", specification.ClothingEra);
AddMetadata("clothingStyle", specification.ClothingStyle);
}
var metadataJson = JsonSerializer.Serialize(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
- subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
- balanced cinematic 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 controlled colour with rich but restrained accents 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
- for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; make hair colour plainly visible at thumbnail size and do not reinterpret or diversify away from it
- for character images, vary face shape, nose, eyes, mouth, jaw, expression, hairstyle silhouette, and posture so reusable portraits do not look like siblings unless the specification explicitly asks for family resemblance
- no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity
- 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);
void AddMetadata(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
metadata[key] = value;
}
}
}
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 a subtle atmospheric story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and photographic headshot styling.",
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}",
$"required skin tone {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 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<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",
["generationSource"] = "Starter batch"
};
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,
IUploadStorageService uploadStorage) : 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 => 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<string, List<IllustrationLibraryItem>> 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)));
}