From f0b444e0eef7c87526f085a9d5fdb6decdd1655b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 12 Jul 2026 14:16:56 +0000 Subject: [PATCH] Phase 21E - Connect Illustration Library Images --- PlotLine.Tests/Program.cs | 23 +++ .../Services/IllustrationLibraryServices.cs | 159 +++++++++++++----- ...telligenceExperiencePrototypeViewModels.cs | 14 ++ .../StoryIntelligenceExperience.cshtml | 2 +- ...story-intelligence-experience-prototype.js | 74 +++++++- 5 files changed, 227 insertions(+), 45 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 995e009..fe5ed17 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -33,6 +33,7 @@ var tests = new (string Name, Action Test)[] ("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely), ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("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 skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), @@ -336,9 +337,14 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt() var result = builder.Build(spec); 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("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("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.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."); @@ -427,6 +433,23 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems() 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() { var result = new IllustrationBulkRetryResult(12, 4, 3); diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index 1aa3e79..765892d 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder { - public const string CurrentTemplateVersion = "21D.1"; + public const string CurrentTemplateVersion = "21D.2"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -56,12 +56,17 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder 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 + - modern cinematic digital illustration with premium narrative game concept art quality + - vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions + - bright balanced lighting, clear facial illumination where faces are present, and strong local contrast + - crisp subject definition with strong foreground-background separation and clean readable silhouettes + - natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate + - polished promotional artwork for a premium story-driven adventure game, not a literal photograph + - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface - no visible text, captions, logos, watermarks, UI, signatures, or typography - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters + - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results - square composition that still reads clearly as a small thumbnail """; @@ -71,9 +76,9 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder 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.", + IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.", + IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.", + IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.", _ => string.Empty }; @@ -99,34 +104,34 @@ public static class IllustrationStarterBatchDefinition { public static IReadOnlyList All { get; } = [ - Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with 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-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 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-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", "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"]), - 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-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 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"]), - 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-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", "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-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-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"]) + Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail and bright edge separation", "memory, proof", ["vintage colour", "cream border", "clean graphite"]), + Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["fresh green", "paper tan", "red accent"]) ]; public static IReadOnlySet StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -592,7 +597,8 @@ public interface IIllustrationLibraryService public sealed class IllustrationLibraryService( IIllustrationLibraryRepository repository, IIllustrationPromptBuilder promptBuilder, - IIllustrationImageProvider provider) : IIllustrationLibraryService + IIllustrationImageProvider provider, + IUploadStorageService uploadStorage) : IIllustrationLibraryService { private const int MaxGenerationAttempts = 3; @@ -894,34 +900,109 @@ public sealed class IllustrationLibraryService( 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) + .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.First().FilePath!, StringComparer.OrdinalIgnoreCase); + .ToDictionary(group => group.Key, group => group.ToList(), 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; - } + 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) { - if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath)) - { - character.ImagePath = characterPath; - } + 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) { - if (!string.IsNullOrWhiteSpace(asset.LibraryCode) && lookup.TryGetValue(asset.LibraryCode, out var assetPath)) - { - asset.ImagePath = assetPath; - } + var fallback = asset.ImagePath; + var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup); + asset.ImageResolution = resolution; + asset.ImagePath = resolution.FinalImageUrl ?? fallback; } } } + + private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage( + string entityId, + string stableCode, + string fallbackImageUrl, + IReadOnlyDictionary> lookup) + { + if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates)) + { + return PrototypeFallback(entityId, stableCode, fallbackImageUrl); + } + + foreach (var candidate in candidates) + { + var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); + if (publicUrl is null) + { + continue; + } + + return new() + { + EntityId = entityId, + StableCode = stableCode, + IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, + Status = candidate.Status, + FinalImageUrl = publicUrl, + FallbackImageUrl = fallbackImageUrl, + FallbackUsed = false + }; + } + + var firstCandidate = candidates.FirstOrDefault(); + return new() + { + EntityId = entityId, + StableCode = stableCode, + IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID, + Status = firstCandidate?.Status, + FallbackImageUrl = fallbackImageUrl, + FallbackUsed = true + }; + } + + private string? ResolveExistingPublicUploadUrl(string? storedPath) + { + var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); + if (physicalPath is null || !File.Exists(physicalPath)) + { + return null; + } + + var normalised = storedPath!.Replace('\\', '/').Trim(); + if (!normalised.StartsWith('/')) + { + normalised = "/" + normalised.TrimStart('/'); + } + + return EncodeUrlPath(normalised); + } + + private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl) + => new() + { + EntityId = entityId, + StableCode = stableCode, + FallbackImageUrl = fallbackImageUrl, + FallbackUsed = true + }; + + private static string EncodeUrlPath(string path) + => string.Join( + '/', + path.Split('/', StringSplitOptions.None) + .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment))); } diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 0a01826..7c18127 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -42,6 +42,7 @@ public sealed class StoryIntelligenceExperienceCharacterState public string Relevance { get; init; } = string.Empty; public int Weight { get; init; } public string ImagePath { get; set; } = string.Empty; + public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new(); } public sealed class StoryIntelligenceExperienceLocationState @@ -51,6 +52,7 @@ public sealed class StoryIntelligenceExperienceLocationState public string Name { get; init; } = string.Empty; public string Label { get; init; } = string.Empty; public string ImagePath { get; set; } = string.Empty; + public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new(); } public sealed class StoryIntelligenceExperienceAssetState @@ -61,6 +63,18 @@ public sealed class StoryIntelligenceExperienceAssetState public string Label { get; init; } = string.Empty; public int Weight { get; init; } 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 diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 72744ed..58f6cc4 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -15,7 +15,7 @@ -
+
PlotDirector diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 975eff7..471cb31 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -8,6 +8,8 @@ if (!scenes.length) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const failedImageUrls = new Set(); + const preloadedImageUrls = new Set(); const state = { index: 0, playing: true, @@ -65,6 +67,9 @@ const previous = loadSceneState(state.index); state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex)); const current = loadSceneState(state.index); + preloadSceneImages(current); + preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1))); + logImageDiagnostics(current); updateProgress(current); updateAnalysisStages(current); @@ -128,6 +133,67 @@ 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) { const activeIds = new Set(scene.characters.map((character) => character.id)); scene.characters.forEach((character, index) => { @@ -144,8 +210,7 @@ node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--opacity", String(position.opacity)); - node.querySelector("img").src = character.imagePath; - node.querySelector("img").alt = `${character.name} prototype portrait`; + setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`); text(node.querySelector("[data-character-name]"), character.name); 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("--x", `${position.x}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("p"), scene.location.label); dom.location.append(node); @@ -206,8 +271,7 @@ node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--opacity", "1"); - node.querySelector("img").src = asset.imagePath; - node.querySelector("img").alt = `${asset.name} prototype asset`; + setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`); text(node.querySelector("[data-asset-name]"), asset.name); text(node.querySelector("[data-asset-label]"), asset.label); });