diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 2d164fb..42a1c39 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1108,6 +1108,10 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group."); Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era."); Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location."); + Assert(onboarding.Contains(" Use explicit manuscript evidence only - - - - - +
Story context for generated portraits

These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.

diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 97c8381..f24057c 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -287,16 +287,26 @@ input:focus-visible { } .story-exp-rebuild { + position: fixed; + left: 50%; + top: 66px; + z-index: 18; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 9px; - margin: 0 18px 10px; + width: clamp(320px, calc(100vw - 520px), 720px); + min-height: 42px; + max-height: 74px; + margin: 0; border: 1px solid rgba(151, 185, 218, 0.12); border-radius: 999px; padding: 8px 10px; background: rgba(4, 12, 24, 0.74); + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.22); + transform: translateX(-50%); + overflow: hidden; } .story-exp-rebuild label { @@ -307,6 +317,7 @@ input:focus-visible { font-size: 0.76rem; font-weight: 800; text-transform: uppercase; + white-space: nowrap; } .story-exp-rebuild select, @@ -319,7 +330,7 @@ input:focus-visible { } .story-exp-rebuild select { - max-width: min(280px, 40vw); + max-width: min(240px, 24vw); padding: 6px 22px 6px 9px; } @@ -886,6 +897,66 @@ input:focus-visible { opacity: 0.9; } +.story-character.is-evidence-card { + width: min(172px, calc(var(--node-size, 150px) + 44px)); + height: auto; +} + +.story-character.is-evidence-card .story-character__image { + display: none; +} + +.story-character.is-evidence-card .story-character__text { + position: relative; + top: 0; + left: 0; + width: min(172px, calc(var(--node-size, 150px) + 44px)); + min-height: 86px; + justify-items: start; + gap: 5px; + border-color: rgba(169, 205, 240, 0.22); + border-radius: 12px; + padding: 10px 11px; + background: + linear-gradient(180deg, rgba(16, 34, 58, 0.94), rgba(5, 13, 25, 0.9)); + text-align: left; + transform: none; +} + +.story-character.is-evidence-card.is-pov .story-character__text { + border-color: rgba(215, 168, 93, 0.68); + box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 24px 56px rgba(0, 0, 0, 0.3); +} + +.story-character.is-evidence-card .story-character__text strong, +.story-character.is-evidence-card .story-character__text span { + text-align: left; +} + +.story-entity-facts { + display: flex; + flex-wrap: wrap; + gap: 4px; + max-width: 100%; +} + +.story-entity-facts span { + display: inline-flex; + max-width: 100%; + border: 1px solid rgba(151, 185, 218, 0.12); + border-radius: 999px; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.045); + color: var(--story-soft); + font-size: 0.61rem; + font-weight: 800; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + white-space: nowrap; +} + .story-location { position: absolute; display: grid; @@ -994,6 +1065,30 @@ input:focus-visible { text-align: center; } +.story-asset.is-evidence-card { + width: 132px; +} + +.story-asset.is-evidence-card .story-asset__image { + display: none; +} + +.story-asset.is-evidence-card .story-asset__text { + justify-items: start; + width: 132px; + min-height: 64px; + border-color: rgba(215, 168, 93, 0.18); + padding: 8px 9px; + background: + linear-gradient(180deg, rgba(34, 29, 18, 0.88), rgba(7, 13, 21, 0.84)); + text-align: left; +} + +.story-asset.is-evidence-card .story-asset__text strong, +.story-asset.is-evidence-card .story-asset__text span { + text-align: left; +} + .story-asset strong, .story-asset span { display: block; @@ -1013,6 +1108,15 @@ input:focus-visible { font-size: 0.64rem; } +.story-asset .story-entity-facts span { + display: inline-flex; + width: auto; + max-width: 100%; + border-radius: 999px; + line-height: 1.2; + text-overflow: ellipsis; +} + .story-exp-ribbon-wrap { display: grid; grid-template-rows: auto minmax(104px, auto) 56px; diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index fd8942f..4817325 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -231,6 +231,13 @@ function setEntityImage(image, url, resolution, altText) { if (!image) return; image.alt = altText; + if (!url) { + image.removeAttribute("src"); + image.dataset.fallbackUrl = ""; + image.dataset.entityId = resolution?.entityId || ""; + image.dataset.libraryCode = resolution?.stableCode || ""; + return; + } const fallbackUrl = resolution?.fallbackImageUrl || url; const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url; image.dataset.fallbackUrl = fallbackUrl || ""; @@ -249,6 +256,115 @@ } } + function shouldUseCharacterPortrait(character) { + const resolution = character?.imageResolution || {}; + if (resolution.fallbackUsed || !resolution.finalImageUrl) return false; + const facts = characterVisualFacts(character); + if (facts.length < 2) return false; + const confidence = Number(resolution.assignmentConfidence || 0); + const score = Number(resolution.matchingScore || 0); + return confidence >= 0.78 || score >= 82; + } + + function shouldUseAssetImage(asset) { + const resolution = asset?.imageResolution || {}; + if (resolution.fallbackUsed || !resolution.finalImageUrl) return false; + const confidence = Number(resolution.assignmentConfidence || 0); + const score = Number(resolution.matchingScore || 0); + const code = String(resolution.stableCode || asset?.libraryCode || "").toLowerCase(); + if (!code || code.includes("generic") || code.includes("unknown")) return false; + return confidence >= 0.86 || score >= 90; + } + + function renderFactChips(container, facts) { + if (!container) return; + container.replaceChildren(...facts.slice(0, 4).map((fact) => { + const chip = document.createElement("span"); + chip.textContent = fact; + return chip; + })); + } + + function characterFacts(character, isPov) { + const facts = []; + if (isPov) facts.push("POV"); + facts.push(...characterVisualFacts(character)); + const role = conciseCharacterLabel(character, isPov); + if (role && !facts.some((fact) => sameFact(fact, role))) facts.push(role); + if (!facts.length) facts.push(character.weight >= 70 ? "significant" : "identified"); + if (!shouldUseCharacterPortrait(character)) facts.push("appearance pending"); + return uniqueFacts(facts); + } + + function characterVisualFacts(character) { + const resolution = character?.imageResolution || {}; + const evidence = parseEvidence(resolution.currentEvidence); + const facts = [ + displayEvidenceValue(evidence.age || resolution.resolvedAgeBand), + displayEvidenceValue(evidence.presentation || resolution.resolvedPresentation), + displayHairFact(evidence.hair, evidence.hairLength), + displaySkinFact(evidence.skin) + ]; + return uniqueFacts(facts.filter(Boolean)); + } + + function assetFacts(asset) { + const facts = []; + const label = conciseAssetLabel(asset); + if (label) facts.push(label); + if (!shouldUseAssetImage(asset)) facts.push("text card"); + return uniqueFacts(facts); + } + + function parseEvidence(value) { + const result = {}; + String(value || "").split(";").forEach((part) => { + const [key, raw] = part.split("="); + if (!key || raw === undefined) return; + result[key.trim().toLowerCase()] = raw.trim(); + }); + return result; + } + + function displayEvidenceValue(value) { + const clean = String(value || "").trim(); + if (!clean || clean.toLowerCase() === "unknown") return ""; + return clean + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\bOlder Teen\b/i, "late teen") + .replace(/\bYoung Adult\b/i, "young adult") + .toLowerCase(); + } + + function displayHairFact(colour, length) { + const hairColour = displayEvidenceValue(colour); + if (!hairColour) return ""; + const hairLength = displayEvidenceValue(length); + return `${hairLength && hairLength !== "medium" ? `${hairLength} ` : ""}${hairColour} hair`; + } + + function displaySkinFact(value) { + const skin = displayEvidenceValue(value); + return skin ? `${skin} skin` : ""; + } + + function uniqueFacts(facts) { + const seen = new Set(); + return facts + .map((fact) => String(fact || "").trim()) + .filter((fact) => { + if (!fact) return false; + const key = fact.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + function sameFact(a, b) { + return String(a || "").trim().toLowerCase() === String(b || "").trim().toLowerCase(); + } + function preloadSceneImages(scene) { if (!scene) return; collectSceneImages(scene).forEach((url) => { @@ -319,10 +435,13 @@ node.style.setProperty("--opacity", String(position.opacity)); node.style.setProperty("--z", String(position.z)); node.dataset.layoutRole = position.layoutRole; - setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`); + const portraitReady = shouldUseCharacterPortrait(character); + node.classList.toggle("is-evidence-card", !portraitReady); + setEntityImage(node.querySelector("img"), portraitReady ? character.imagePath : "", character.imageResolution, `${character.name} prototype portrait`); text(node.querySelector("[data-character-name]"), character.name); const label = conciseCharacterLabel(character, character.id === scene.povCharacterId); text(node.querySelector("[data-character-role]"), label); + renderFactChips(node.querySelector("[data-character-facts]"), characterFacts(character, character.id === scene.povCharacterId)); node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`; }); @@ -389,9 +508,12 @@ node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--z", String(position.z)); node.style.setProperty("--opacity", "1"); - setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`); + const assetImageReady = shouldUseAssetImage(asset); + node.classList.toggle("is-evidence-card", !assetImageReady); + setEntityImage(node.querySelector("img"), assetImageReady ? asset.imagePath : "", asset.imageResolution, `${asset.name} prototype asset`); text(node.querySelector("[data-asset-name]"), asset.name); text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset)); + renderFactChips(node.querySelector("[data-asset-facts]"), assetFacts(asset)); node.title = `${asset.name}: ${asset.label}`; }); @@ -708,6 +830,7 @@
+
`; return node; } @@ -721,6 +844,7 @@
+
`; return node; }