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