JR-004, JR-005, JR-006: scene-scoped read path

Presence was decided by a LINQ predicate inline in the controller, so the
semantics SR-002 sets were nowhere stated in code -- the read path complied by
accident rather than by requirement. PresenceLookup is now the unit that
decides, tagged, with the reasoning next to it.

JR-004: windows are served exactly as given. UT-021 pins that [0,10] and
[10,20] are not merged despite looking mergeable -- two windows mean a genuine
departure and return, and collapsing them answers a different question from the
one the truth file asked. UT-022 pins a byte-identical round trip.

JR-005: bounds inclusive at both ends, zero-length windows are real sightings
rather than degenerate ones to discard, overlaps resolve.

The wording was the larger half of JR-005. The overlay rendered a bare list: it
asserted nothing, but told the viewer nothing either, and the default reading of
a paused frame is "these people are on screen" -- exactly what SR-002 forbids.
It now carries an "In this scene" heading. ActorAtTime became ActorInScene, and
README no longer contains "on screen" anywhere; it stated the forbidden reading
outright in seven places, including the opening sentence.

JR-006: measured rather than assumed. UT-023 builds 50 actors x 1000 windows and
asserts the response is bounded by actor count, never window count. The lookup
is a full scan on purpose -- an early exit on `start > t` would exploit the
sortedness the format requires, but would silently under-report the moment one
producer emitted windows out of order. UT-020 pins that unsorted input still
resolves; WindowsAreSorted is a diagnostic, not a correctness dependency.

Third mutation check: making the end bound exclusive fails UT-016 and UT-018 and
nothing else. One character turns an inclusive window into a half-open one,
dropping an actor at exactly the moment a scene ends.

TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023
TRACES: JR-004, JR-005, JR-006 | SR-002

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:29:46 +02:00
co-authored by Claude Opus 5
parent 305b898b15
commit c04d5a3dcc
11 changed files with 378 additions and 47 deletions
@@ -1,8 +1,8 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -38,7 +38,7 @@ public class ActorsController : ControllerBase
}
/// <summary>
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
/// Gets the full actor timeline (every actor with their scene-presence windows) for a movie.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -58,7 +58,7 @@ public class ActorsController : ControllerBase
}
/// <summary>
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
/// Gets the JRay context (currently: the actors in the scene) at a given timestamp.
/// This is an extensible envelope — future fields (locations, trivia, etc.)
/// will be added here without changing the route.
/// </summary>
@@ -78,9 +78,9 @@ public class ActorsController : ControllerBase
}
var context = new JRayContext();
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
foreach (var actor in PresenceLookup.ActorsPresentAt(truth, t))
{
context.Actors.Add(new ActorAtTime
context.Actors.Add(new ActorInScene
{
Name = actor.Name,
ImdbId = actor.ImdbId,
@@ -3,9 +3,17 @@ using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// An actor visible on screen at a queried timestamp.
/// An actor present in the scene at a queried timestamp.
/// </summary>
public class ActorAtTime
/// <remarks>
/// "Present in the scene", not "visible on screen". The truth file makes a claim
/// about scene membership, so an actor who has turned away or is off-camera
/// during a reverse shot is still present. The type was named
/// <c>ActorAtTime</c>, which invited exactly the instantaneous reading SR-002
/// forbids.
/// </remarks>
// TRACES: JR-005 | SR-002
public class ActorInScene
{
/// <summary>
/// Gets or sets the actor's display name.
+2 -2
View File
@@ -11,8 +11,8 @@ namespace Jellyfin.Plugin.JRay.Models;
public class JRayContext
{
/// <summary>
/// Gets the list of actors visible on screen at the queried timestamp.
/// Gets the list of actors visible in the scene at the queried timestamp.
/// </summary>
[JsonPropertyName("actors")]
public Collection<ActorAtTime> Actors { get; } = new();
public Collection<ActorInScene> Actors { get; } = new();
}
+1 -1
View File
@@ -45,7 +45,7 @@ public class TruthActor
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
/// Gets the list of [start_sec, end_sec] windows during which the actor is in the scene.
/// </summary>
[JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+1 -1
View File
@@ -45,7 +45,7 @@ public class TruthFile
public double AnnealSec { get; set; }
/// <summary>
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
/// Gets the list of actors in the film, each with their scene-presence windows.
/// </summary>
[JsonPropertyName("actors")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Answers "which actors are in the scene at time <c>t</c>" from a truth file.
/// </summary>
/// <remarks>
/// This is the unit that decides presence, so the scene-scoped semantics live
/// here rather than being spread through the controller.
///
/// <para>
/// <b>A window is a claim about scene membership, not a recognition event.</b>
/// An actor who turns away, is occluded, or is off-camera while the shot cuts to
/// whoever they are speaking to is still present. Two windows mean a genuine
/// departure and return, not a break in detection — so this code reads windows
/// exactly as given and never merges, splits, trims, or reorders them.
/// </para>
/// <para>
/// Bounds are inclusive at both ends, matching the format's definition. That
/// makes adjacent windows such as <c>[0,10]</c> and <c>[10,20]</c> both contain
/// <c>t = 10</c>; reporting the actor present once is correct, and is not a
/// reason to merge the windows.
/// </para>
/// </remarks>
// TRACES: JR-004, JR-005, JR-006 | SR-002
public static class PresenceLookup
{
/// <summary>
/// Determines whether an actor is present in the scene at <paramref name="t"/>.
/// </summary>
/// <param name="actor">The actor entry from a truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns><c>true</c> when any window contains <paramref name="t"/>.</returns>
public static bool IsPresentAt(TruthActor actor, double t)
{
if (actor is null)
{
return false;
}
// A full scan, deliberately: windows may be numerous, but correctness
// must not depend on the producer having honoured the sortedness
// guarantee. An early exit on `start > t` would be faster and would
// silently under-report the moment one file arrived out of order —
// trading a correctness risk for a saving that does not matter at this
// scale (see JR-006).
foreach (var window in actor.Scenes)
{
if (window.Length == 2 && window[0] <= t && t <= window[1])
{
return true;
}
}
return false;
}
/// <summary>
/// Lists the actors present in the scene at <paramref name="t"/>, in the
/// order the truth file lists them.
/// </summary>
/// <param name="truth">The truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns>The actors whose windows contain <paramref name="t"/>.</returns>
public static IEnumerable<TruthActor> ActorsPresentAt(TruthFile truth, double t)
{
if (truth is null)
{
yield break;
}
foreach (var actor in truth.Actors)
{
if (IsPresentAt(actor, t))
{
yield return actor;
}
}
}
/// <summary>
/// Determines whether an actor's windows are sorted by start time, as the
/// truth-file format requires of producers.
/// </summary>
/// <remarks>
/// Presence lookup does not depend on this — it is a diagnostic. A file that
/// fails it is still read correctly, but it signals a producer bug worth
/// surfacing rather than absorbing silently.
/// </remarks>
/// <param name="actor">The actor entry from a truth file.</param>
/// <returns><c>true</c> when every window starts at or after its predecessor.</returns>
public static bool WindowsAreSorted(TruthActor actor)
{
if (actor is null)
{
return true;
}
double previousStart = double.NegativeInfinity;
foreach (var window in actor.Scenes)
{
if (window.Length != 2)
{
continue;
}
if (window[0] < previousStart)
{
return false;
}
previousStart = window[0];
}
return true;
}
}
+16
View File
@@ -218,6 +218,22 @@
overlayEl.style.gap = '12px';
overlayEl.style.pointerEvents = 'none';
// "In this scene", not "on screen now". Presence is scene-scoped, so
// this list includes people the camera is not currently pointing at —
// without the heading a viewer reads a paused frame and concludes the
// overlay is wrong whenever someone is off-camera mid-conversation.
var heading = document.createElement('div');
heading.className = 'jrayOverlayHeading';
heading.textContent = 'In this scene';
heading.style.width = '100%';
heading.style.color = '#fff';
heading.style.opacity = '0.75';
heading.style.fontSize = '13px';
heading.style.textTransform = 'uppercase';
heading.style.letterSpacing = '0.08em';
heading.style.textShadow = '0 1px 3px rgba(0,0,0,0.9)';
overlayEl.appendChild(heading);
actors.forEach(function (actor) {
var card = document.createElement('div');
card.className = 'jrayActorCard';