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:
@@ -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,
|
||||
|
||||
+10
-2
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user