Phase 21J - Story Memory Visualisation Refinement

This commit is contained in:
Nick Beckley 2026-07-12 20:37:49 +00:00
parent b78a9bea9b
commit 4ebbfdab92
5 changed files with 304 additions and 19 deletions

View File

@ -115,7 +115,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.ToList();
var current = PickCurrentScene(run, completed);
var scenes = BuildSceneStates(run, parsed, completed, current).ToList();
var storyMemory = new StoryMemory();
var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory).ToList();
if (scenes.Count == 0)
{
scenes.Add(BuildEmptyScene(run));
@ -130,7 +131,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChangeToken = ChangeToken(run, results),
IsTerminal = IsTerminal(run.Status),
SnapshotMessage = SnapshotMessage(run, completed.Count),
Diagnostics = BuildDiagnostics(run, results, parsed, completed, current),
Diagnostics = BuildDiagnostics(run, results, parsed, completed, current, storyMemory),
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
Scenes = scenes
@ -141,6 +142,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
if (run.ProjectID.HasValue)
{
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
ApplyIllustrationDiagnostics(model);
}
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
return model;
@ -198,6 +200,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
var scenes = new List<StoryIntelligenceExperienceSceneState>();
var nextSceneNumber = 1;
var storyMemory = new StoryMemory();
foreach (var snapshot in runSnapshots)
{
var runScenes = BuildSceneStates(
@ -215,7 +218,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
overallElapsed: overallElapsed,
overallRemaining: overallRemaining,
continuousSceneNumbering: true,
overallTerminal: isTerminal)
overallTerminal: isTerminal,
storyMemory: storyMemory)
.ToList();
scenes.AddRange(runScenes);
nextSceneNumber += snapshot.Completed.Count;
@ -236,7 +240,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChangeToken = SessionChangeToken(session, runSnapshots),
IsTerminal = isTerminal,
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, totalScenes, overallProgressPercent),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, totalScenes, overallProgressPercent, storyMemory),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes
@ -245,6 +249,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
ApplyIllustrationDiagnostics(model);
logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds);
return model;
}
@ -264,14 +269,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
string? overallElapsed = null,
string? overallRemaining = null,
bool continuousSceneNumbering = false,
bool overallTerminal = false)
bool overallTerminal = false,
StoryMemory? storyMemory = null)
{
storyMemory ??= new StoryMemory();
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
var completedScenes = run.CompletedScenes ?? completed.Count;
var displayIndex = 0;
foreach (var item in completed)
{
var scene = item.Scene!;
storyMemory.Update(scene);
var sourceSceneNumber = SceneNumber(item);
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
@ -295,11 +303,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes),
ElapsedTime = overallElapsed ?? EstimateElapsed(run),
EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes),
PovCharacterId = CharacterId(ResolveNarratorName(scene) ?? scene.PointOfView?.CharacterName),
PovCharacterId = CharacterId(storyMemory.ResolvePointOfView(scene)),
Location = BuildLocation(scene),
Characters = BuildCharacters(scene),
Characters = BuildCharacters(scene, storyMemory),
Assets = BuildAssets(scene),
Relationships = BuildRelationships(scene),
Relationships = BuildRelationships(scene, storyMemory),
KnowledgeThreads = BuildKnowledge(scene),
Observations = BuildObservations(scene),
LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [],
@ -341,12 +349,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return completed.Last();
}
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
{
var narratorResolution = ResolveNarratorName(scene);
var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene);
var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName);
var states = new List<StoryIntelligenceExperienceCharacterState>();
var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sceneCharacterNames = new HashSet<string>((scene.Characters ?? []).Select(character => Clean(character.Name)), StringComparer.OrdinalIgnoreCase);
foreach (var group in (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
@ -388,6 +397,28 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
});
}
if (storyMemory is not null)
{
foreach (var memoryCharacter in storyMemory.Characters
.Where(character => !sceneCharacterNames.Contains(character.Name))
.OrderByDescending(character => character.Weight)
.Take(Math.Max(0, 5 - states.Count)))
{
var libraryCode = CharacterLibraryCode(memoryCharacter.Name, null, scene, reservedCodes);
reservedCodes.Add(libraryCode);
states.Add(new StoryIntelligenceExperienceCharacterState
{
Id = CharacterId(memoryCharacter.Name),
LibraryCode = libraryCode,
Name = memoryCharacter.Name,
Role = memoryCharacter.Role,
Relevance = memoryCharacter.Relevance,
Weight = Math.Max(38, memoryCharacter.Weight - 18),
ImagePath = CharacterFallback(memoryCharacter.Name)
});
}
}
return states.OrderByDescending(character => character.Weight).ToList();
}
@ -493,8 +524,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.Take(5)
.ToList();
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> BuildRelationships(SceneIntelligenceScene scene)
=> (scene.Relationships ?? [])
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> BuildRelationships(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
{
var current = (scene.Relationships ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.CharacterA) && !string.IsNullOrWhiteSpace(item.CharacterB))
.Select(item => new StoryIntelligenceExperienceRelationshipState
{
@ -505,9 +537,26 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
State = Trim(FirstConfigured(item.Evidence, item.RelationshipSignal, "Emerging"), 58),
Weight = ConfidenceWeight(item.Confidence, 70)
})
.OrderByDescending(item => item.Weight)
.ToList();
var seen = new HashSet<string>(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase);
var memoryRelationships = (storyMemory?.Relationships ?? [])
.Where(item => seen.Add($"{item.SourceId}|{item.TargetId}"))
.Select(item => new StoryIntelligenceExperienceRelationshipState
{
Id = item.Id,
SourceId = item.SourceId,
TargetId = item.TargetId,
Label = item.Label,
State = item.State,
Weight = Math.Max(44, item.Weight - 16)
});
return current.Concat(memoryRelationships)
.OrderByDescending(item => item.Weight)
.Take(4)
.ToList();
}
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildKnowledge(SceneIntelligenceScene scene)
{
@ -733,6 +782,179 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
: "All known chapter runs are complete. Awaiting the next book import stage.";
}
private sealed class StoryMemory
{
private readonly Dictionary<string, MemoryCharacter> characters = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, StoryIntelligenceExperienceRelationshipState> relationships = new(StringComparer.OrdinalIgnoreCase);
private string? narratorResolvedName;
public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values;
public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values;
public decimal NarratorConfidence { get; private set; }
public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved);
public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved);
public void Update(SceneIntelligenceScene scene)
{
var resolvedNarrator = ResolveNarratorName(scene);
if (!string.IsNullOrWhiteSpace(resolvedNarrator))
{
narratorResolvedName = resolvedNarrator;
NarratorConfidence = Math.Max(NarratorConfidence, 0.82m);
}
else if (IsGenericNarratorName(scene.PointOfView?.CharacterName))
{
NarratorConfidence = Math.Max(NarratorConfidence, 0.35m);
}
foreach (var character in scene.Characters ?? [])
{
var name = Clean(character.Name);
if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
{
if (!string.IsNullOrWhiteSpace(narratorResolvedName))
{
name = narratorResolvedName;
}
else
{
name = "Narrator";
}
}
var memory = GetOrAddCharacter(name);
memory.Weight = Math.Max(memory.Weight, CharacterWeight(character));
memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60);
ApplyInference(memory, $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}");
}
var povName = ResolvePointOfView(scene);
if (!string.IsNullOrWhiteSpace(povName))
{
var pov = GetOrAddCharacter(povName);
pov.PovCount += 1;
pov.Weight = Math.Max(pov.Weight, 100);
pov.Role = "POV character";
pov.Relevance = pov.PovCount > 1 ? "Recurring point of view" : "Point of view";
}
foreach (var relationship in scene.Relationships ?? [])
{
var a = ResolveCharacterName(relationship.CharacterA);
var b = ResolveCharacterName(relationship.CharacterB);
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
continue;
}
ApplyInference(GetOrAddCharacter(a), $"{relationship.RelationshipSignal} {relationship.Evidence}");
ApplyInference(GetOrAddCharacter(b), $"{relationship.RelationshipSignal} {relationship.Evidence}");
var key = $"{CharacterId(a)}|{CharacterId(b)}";
relationships[key] = new StoryIntelligenceExperienceRelationshipState
{
Id = $"memory-relationship-{StableKey(a)}-{StableKey(b)}",
SourceId = CharacterId(a),
TargetId = CharacterId(b),
Label = Trim(FirstConfigured(relationship.RelationshipSignal, "Relationship remembered"), 44),
State = Trim(FirstConfigured(relationship.Evidence, relationship.RelationshipSignal, "Carried forward from earlier scenes"), 58),
Weight = ConfidenceWeight(relationship.Confidence, 64)
};
}
}
public string? ResolvePointOfView(SceneIntelligenceScene scene)
{
var raw = Clean(scene.PointOfView?.CharacterName);
if (IsGenericNarratorName(raw))
{
return narratorResolvedName ?? raw;
}
return string.IsNullOrWhiteSpace(raw) ? narratorResolvedName : raw;
}
private string ResolveCharacterName(string? raw)
{
var name = Clean(raw);
return IsGenericNarratorName(name) && !string.IsNullOrWhiteSpace(narratorResolvedName) ? narratorResolvedName! : name;
}
private MemoryCharacter GetOrAddCharacter(string name)
{
var key = CharacterId(name);
if (!characters.TryGetValue(key, out var character))
{
character = new MemoryCharacter(name);
characters[key] = character;
}
return character;
}
private static void ApplyInference(MemoryCharacter character, string evidence)
{
var text = evidence.ToLowerInvariant();
if (ContainsAny(text, "mother", "daughter", "sister", "wife", "she ", " her "))
{
character.Presentation = "Feminine";
}
else if (ContainsAny(text, "father", "son", "brother", "husband", " he ", " him "))
{
character.Presentation = "Masculine";
}
else
{
character.Presentation ??= NamePresentation(character.Name);
}
if (ContainsAny(text, "grandfather", "grandmother", "elderly"))
{
character.AgeBand = "Elderly";
}
else if (ContainsAny(text, "mother", "father", "parent", "middle aged", "middle-aged"))
{
character.AgeBand = "Middle Aged";
}
else if (ContainsAny(text, "teenage", "teenager", "fifteen", "sixteen", "seventeen"))
{
character.AgeBand = "Older Teen";
}
}
private static string? NamePresentation(string name)
{
var key = name.ToLowerInvariant();
if (key is "beth" or "rosie" or "maggie" or "annie" or "anna" or "anne")
{
return "Feminine";
}
if (key is "graham" or "simon" or "kevin" or "colin" or "john" or "david")
{
return "Masculine";
}
return null;
}
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
}
private sealed class MemoryCharacter(string name)
{
public string Name { get; } = name;
public string Role { get; set; } = "Remembered character";
public string Relevance { get; set; } = "Known from earlier scenes";
public int Weight { get; set; } = 48;
public int PovCount { get; set; }
public string? Presentation { get; set; }
public string? AgeBand { get; set; }
public bool IsResolved => !IsGenericNarratorName(Name);
}
private static string CurrentSceneText(RunSnapshot snapshot)
{
if (snapshot.Run.TotalDetectedScenes.HasValue)
@ -773,7 +995,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
int parsedScenes,
int totalPersistedResults,
int totalScenes,
int overallProgressPercent)
int overallProgressPercent,
StoryMemory storyMemory)
{
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
@ -792,6 +1015,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
TotalScenes = totalScenes,
ProgressSource = "ImportSession",
CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run),
StoryMemoryCharacterCount = storyMemory.Characters.Count,
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
SnapshotSource = "Real",
RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
@ -813,7 +1040,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
IReadOnlyList<ParsedSceneResult> parsed,
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current)
ParsedSceneResult? current,
StoryMemory storyMemory)
{
var failed = parsed.Where(item => item.Scene is null).ToList();
var warning = failed.Count == 0
@ -834,6 +1062,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
TotalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count),
ProgressSource = "ChapterRun",
CurrentBackendStage = BackendStageDisplay(run),
StoryMemoryCharacterCount = storyMemory.Characters.Count,
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
SnapshotSource = "Real",
RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
@ -884,6 +1116,20 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
}
}
private static void ApplyIllustrationDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
{
var characterResolutions = model.Scenes
.SelectMany(scene => scene.Characters)
.Select(character => character.ImageResolution)
.Where(resolution => resolution.IllustrationLibraryItemId.HasValue)
.ToList();
model.Diagnostics.IllustrationAssignmentCount = characterResolutions
.Select(resolution => resolution.IllustrationLibraryItemId!.Value)
.Distinct()
.Count();
model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned));
}
private static bool IsTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
@ -1102,6 +1348,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
{
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
if (value.Contains("bathroom") || value.Contains("toilet") || value.Contains("washroom")) return "loc-laundry-utility-room";
if (value.Contains("kitchen") || value.Contains("desk") || value.Contains("office") || value.Contains("study") || value.Contains("library")) return "loc-domestic-desk";
if (value.Contains("waiting room") || value.Contains("bedroom") || value.Contains("sitting room") || value.Contains("living room")) return "loc-small-flat-interior";
if (value.Contains("laundry") || value.Contains("utility")) return "loc-laundry-utility-room";
if (value.Contains("stair")) return "loc-narrow-stairwell";
if (value.Contains("car park") || value.Contains("parking")) return "loc-wet-car-park";

View File

@ -32,6 +32,12 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public int TotalScenes { get; init; }
public string ProgressSource { get; init; } = string.Empty;
public string CurrentBackendStage { get; init; } = string.Empty;
public int StoryMemoryCharacterCount { get; init; }
public int ResolvedCharacterCount { get; init; }
public int UnresolvedCharacterCount { get; init; }
public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; }
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }

View File

@ -237,6 +237,30 @@
<dt>Current backend stage</dt>
<dd data-diagnostic-key="currentBackendStage">@Model.Diagnostics.CurrentBackendStage</dd>
</div>
<div>
<dt>Story Memory character count</dt>
<dd data-diagnostic-key="storyMemoryCharacterCount">@Model.Diagnostics.StoryMemoryCharacterCount</dd>
</div>
<div>
<dt>Resolved characters</dt>
<dd data-diagnostic-key="resolvedCharacterCount">@Model.Diagnostics.ResolvedCharacterCount</dd>
</div>
<div>
<dt>Unresolved characters</dt>
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
</div>
<div>
<dt>Narrator confidence</dt>
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
</div>
<div>
<dt>Illustration assignments</dt>
<dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd>
</div>
<div>
<dt>Illustration changes</dt>
<dd data-diagnostic-key="illustrationChangeCount">@Model.Diagnostics.IllustrationChangeCount</dd>
</div>
<div>
<dt>Current polling interval</dt>
<dd data-diagnostic-key="currentPollingInterval">Initial</dd>

View File

@ -781,7 +781,7 @@ input:focus-visible {
display: grid;
justify-items: center;
gap: 2px;
width: min(176px, calc(var(--node-size, 150px) + 44px));
width: min(188px, calc(var(--node-size, 150px) + 58px));
max-width: calc(100vw - 32px);
margin-left: 0;
border: 1px solid rgba(151, 185, 218, 0.1);
@ -831,23 +831,27 @@ input:focus-visible {
}
.story-character__text strong {
display: -webkit-box;
overflow: hidden;
max-width: 100%;
font-size: clamp(0.82rem, 0.9vw, 0.98rem);
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.story-character__text span {
display: -webkit-box;
overflow: hidden;
max-width: 100%;
color: var(--story-muted);
font-size: 0.68rem;
font-weight: 700;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.story-character.is-context .story-character__text {

View File

@ -193,6 +193,7 @@
function stageIndex(scene) {
const stage = String(scene.stage || "").toLowerCase();
if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1;
if (stage.includes("final")) return 10;
if (stage.includes("continuity")) return 9;
if (stage.includes("knowledge")) return 8;
if (stage.includes("relationship")) return 7;
@ -202,6 +203,7 @@
if (stage.includes("content") || stage.includes("inferring")) return 3;
if (stage.includes("scene")) return 2;
if (stage.includes("chapter")) return 1;
if (stage.includes("reading") || stage.includes("read ")) return 0;
return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10)));
}