Phase 21E - Connect Illustration Library Images

This commit is contained in:
Nick Beckley 2026-07-12 14:16:56 +00:00
parent 07a5477034
commit f0b444e0ee
5 changed files with 227 additions and 45 deletions

View File

@ -33,6 +33,7 @@ var tests = new (string Name, Action Test)[]
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely), ("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
@ -336,9 +337,14 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
var result = builder.Build(spec); var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded."); Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21D.2", "Illustration prompt template version should reflect the vivid art-direction reset.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance."); Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules."); Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details."); Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("bright balanced lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include brighter lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code."); Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code."); Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata."); Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata.");
@ -427,6 +433,23 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible."); Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
} }
static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
var codes = prototype.Scenes
.SelectMany(scene => scene.Characters.Select(character => character.LibraryCode)
.Concat([scene.Location.LibraryCode])
.Concat(scene.Assets.Select(asset => asset.LibraryCode)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert(codes.Count > 0, "Prototype should carry explicit library codes.");
Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library.");
Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
}
static void IllustrationBulkRetryResultReportsCounts() static void IllustrationBulkRetryResultReportsCounts()
{ {
var result = new IllustrationBulkRetryResult(12, 4, 3); var result = new IllustrationBulkRetryResult(12, 4, 3);

View File

@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{ {
public const string CurrentTemplateVersion = "21D.1"; public const string CurrentTemplateVersion = "21D.2";
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@ -56,12 +56,17 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
Requirements: Requirements:
- representative visual identifier only, not a canonical depiction of any manuscript - representative visual identifier only, not a canonical depiction of any manuscript
- editorial illustration, artistic sketch or painted concept art, stylised realism - modern cinematic digital illustration with premium narrative game concept art quality
- sophisticated and calm, suitable for a dark navy interface - vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions
- calm cinematic lighting and clear silhouette - bright balanced lighting, clear facial illumination where faces are present, and strong local contrast
- crisp subject definition with strong foreground-background separation and clean readable silhouettes
- natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate
- polished promotional artwork for a premium story-driven adventure game, not a literal photograph
- subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- no visible text, captions, logos, watermarks, UI, signatures, or typography - no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters - 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 - square composition that still reads clearly as a small thumbnail
"""; """;
@ -71,9 +76,9 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
private static string CategoryRules(string category) private static string CategoryRules(string category)
=> category switch => 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.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.",
IllustrationLibraryCategories.Location => "One clear focal environment; usable in circular and landscape crops; no prominent people; no text or readable signage.", 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 object; recognisable silhouette; clean subdued background; entire object visible where practical.", 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 _ => string.Empty
}; };
@ -99,34 +104,34 @@ public static class IllustrationStarterBatchDefinition
{ {
public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } = 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-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.", "portrait facing slightly away with alert eyes", "protective, uncertain", ["midnight blue", "copper", "muted rose"]), 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, low directional light", "withheld, imposing", ["charcoal", "steel blue", "dull gold"]), 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 a private sadness.", "soft portrait like a remembered photograph", "warm, elegiac", ["soft grey", "dusty green", "warm ivory"]), 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-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-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-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.", "shadowed portrait with crisp edge light", "tense, unreadable", ["black", "deep green", "brass"]), 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-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-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-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"]), 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-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", "intimate, investigative", ["olive", "warm paper", "deep blue"]), 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 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-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-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-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"]), 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-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 dark surface, blank pages", "investigative, intimate", ["brown leather", "cream paper", "blue shadow"]), 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-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-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-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-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-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-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-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", ["muted green", "paper tan", "red accent"]) 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 IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
@ -592,7 +597,8 @@ public interface IIllustrationLibraryService
public sealed class IllustrationLibraryService( public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository, IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder, IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider) : IIllustrationLibraryService IIllustrationImageProvider provider,
IUploadStorageService uploadStorage) : IIllustrationLibraryService
{ {
private const int MaxGenerationAttempts = 3; private const int MaxGenerationAttempts = 3;
@ -894,34 +900,109 @@ public sealed class IllustrationLibraryService(
var items = await repository.ListAsync(new IllustrationLibraryFilter()); var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items var lookup = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode)) .Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.FilePath is not null && item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1) .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc) .ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().FilePath!, StringComparer.OrdinalIgnoreCase); .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes) foreach (var scene in prototype.Scenes)
{ {
if (!string.IsNullOrWhiteSpace(scene.Location.LibraryCode) && lookup.TryGetValue(scene.Location.LibraryCode, out var locationPath)) var locationFallback = scene.Location.ImagePath;
{ var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
scene.Location.ImagePath = locationPath; scene.Location.ImageResolution = locationResolution;
} scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;
foreach (var character in scene.Characters) foreach (var character in scene.Characters)
{ {
if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath)) var fallback = character.ImagePath;
{ var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
character.ImagePath = characterPath; character.ImageResolution = resolution;
} character.ImagePath = resolution.FinalImageUrl ?? fallback;
} }
foreach (var asset in scene.Assets) foreach (var asset in scene.Assets)
{ {
if (!string.IsNullOrWhiteSpace(asset.LibraryCode) && lookup.TryGetValue(asset.LibraryCode, out var assetPath)) var fallback = asset.ImagePath;
{ var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup);
asset.ImagePath = assetPath; 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)));
} }

View File

@ -42,6 +42,7 @@ public sealed class StoryIntelligenceExperienceCharacterState
public string Relevance { get; init; } = string.Empty; public string Relevance { get; init; } = string.Empty;
public int Weight { get; init; } public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
} }
public sealed class StoryIntelligenceExperienceLocationState public sealed class StoryIntelligenceExperienceLocationState
@ -51,6 +52,7 @@ public sealed class StoryIntelligenceExperienceLocationState
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty; public string Label { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
} }
public sealed class StoryIntelligenceExperienceAssetState public sealed class StoryIntelligenceExperienceAssetState
@ -61,6 +63,18 @@ public sealed class StoryIntelligenceExperienceAssetState
public string Label { get; init; } = string.Empty; public string Label { get; init; } = string.Empty;
public int Weight { get; init; } public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
}
public sealed class StoryIntelligenceExperienceImageResolution
{
public string EntityId { get; init; } = string.Empty;
public string StableCode { get; init; } = string.Empty;
public int? IllustrationLibraryItemId { get; init; }
public string? Status { get; init; }
public string? FinalImageUrl { get; init; }
public string FallbackImageUrl { get; init; } = string.Empty;
public bool FallbackUsed { get; init; } = true;
} }
public sealed class StoryIntelligenceExperienceRelationshipState public sealed class StoryIntelligenceExperienceRelationshipState

View File

@ -15,7 +15,7 @@
<body> <body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script> <script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
<main class="story-exp" data-story-experience> <main class="story-exp" data-story-experience data-image-diagnostics="true">
<header class="story-exp-header" aria-label="Story Intelligence status"> <header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand"> <div class="story-exp-brand">
<strong>PlotDirector</strong> <strong>PlotDirector</strong>

View File

@ -8,6 +8,8 @@
if (!scenes.length) return; if (!scenes.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const failedImageUrls = new Set();
const preloadedImageUrls = new Set();
const state = { const state = {
index: 0, index: 0,
playing: true, playing: true,
@ -65,6 +67,9 @@
const previous = loadSceneState(state.index); const previous = loadSceneState(state.index);
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex)); state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
preloadSceneImages(current);
preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
logImageDiagnostics(current);
updateProgress(current); updateProgress(current);
updateAnalysisStages(current); updateAnalysisStages(current);
@ -128,6 +133,67 @@
text(dom.sceneSummary, scene.summary); text(dom.sceneSummary, scene.summary);
} }
function setEntityImage(image, url, resolution, altText) {
if (!image) return;
image.alt = altText;
const fallbackUrl = resolution?.fallbackImageUrl || url;
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
image.dataset.fallbackUrl = fallbackUrl || "";
image.dataset.entityId = resolution?.entityId || "";
image.dataset.libraryCode = resolution?.stableCode || "";
image.onerror = () => {
const fallback = image.dataset.fallbackUrl;
if (!fallback || image.src.endsWith(fallback)) return;
failedImageUrls.add(url);
image.src = fallback;
};
if (image.getAttribute("src") !== nextUrl) {
image.src = nextUrl;
}
}
function preloadSceneImages(scene) {
if (!scene) return;
collectSceneImages(scene).forEach((url) => {
if (!url || preloadedImageUrls.has(url) || failedImageUrls.has(url)) return;
preloadedImageUrls.add(url);
const image = new Image();
image.onload = () => {};
image.onerror = () => failedImageUrls.add(url);
image.src = url;
});
}
function collectSceneImages(scene) {
return [
scene.location?.imagePath,
...(scene.characters || []).map((character) => character.imagePath),
...(scene.assets || []).map((asset) => asset.imagePath)
].filter(Boolean);
}
function logImageDiagnostics(scene) {
if (!root.dataset.imageDiagnostics) return;
const rows = [];
const push = (type, item) => {
const resolution = item?.imageResolution || {};
rows.push({
type,
entityId: item?.id,
stableCode: resolution.stableCode || item?.libraryCode || "",
recordId: resolution.illustrationLibraryItemId || null,
status: resolution.status || "fallback",
finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
fallbackUsed: !!resolution.fallbackUsed
});
};
push("location", scene.location);
(scene.characters || []).forEach((character) => push("character", character));
(scene.assets || []).forEach((asset) => push("asset", asset));
console.table(rows);
}
function reconcileCharacters(scene) { function reconcileCharacters(scene) {
const activeIds = new Set(scene.characters.map((character) => character.id)); const activeIds = new Set(scene.characters.map((character) => character.id));
scene.characters.forEach((character, index) => { scene.characters.forEach((character, index) => {
@ -144,8 +210,7 @@
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--opacity", String(position.opacity)); node.style.setProperty("--opacity", String(position.opacity));
node.querySelector("img").src = character.imagePath; setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
node.querySelector("img").alt = `${character.name} prototype portrait`;
text(node.querySelector("[data-character-name]"), character.name); text(node.querySelector("[data-character-name]"), character.name);
text(node.querySelector("[data-character-role]"), `${character.role} - ${character.relevance}`); text(node.querySelector("[data-character-role]"), `${character.role} - ${character.relevance}`);
}); });
@ -183,7 +248,7 @@
node.style.setProperty("--node-size", `${position.size}px`); node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`); node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.querySelector("img").src = scene.location.imagePath; setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
text(node.querySelector("h2"), scene.location.name); text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), scene.location.label); text(node.querySelector("p"), scene.location.label);
dom.location.append(node); dom.location.append(node);
@ -206,8 +271,7 @@
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--opacity", "1"); node.style.setProperty("--opacity", "1");
node.querySelector("img").src = asset.imagePath; setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
node.querySelector("img").alt = `${asset.name} prototype asset`;
text(node.querySelector("[data-asset-name]"), asset.name); text(node.querySelector("[data-asset-name]"), asset.name);
text(node.querySelector("[data-asset-label]"), asset.label); text(node.querySelector("[data-asset-label]"), asset.label);
}); });