Files
memby/server/internal/credits/detector_test.go
T
2026-08-15 09:23:26 +12:00

163 lines
5.4 KiB
Go

package credits
import (
"testing"
"time"
)
// The visual changepoint, tested on synthesised frame statistics rather than on media.
//
// findTransition is the whole of the visual decision — everything above it is ffmpeg
// plumbing — and it is pure, so the cases that matter can be built as literals: a clean
// transition, a dark final scene that must not be mistaken for one, and a file with no
// credits at all.
// programmeFrame is an ordinary scene: mid-bright, varied, detailed.
func programmeFrame(position time.Duration) frameStats {
return frameStats{
PositionMs: position.Milliseconds(),
Mean: 0.42,
Variance: 0.055,
DarkFraction: 0.10,
EdgeDensity: 0.075,
Diff: 0.08,
}
}
// creditsFrame is text on black: dark, flat, and textured in the narrow band text produces.
func creditsFrame(position time.Duration) frameStats {
return frameStats{
PositionMs: position.Milliseconds(),
Mean: 0.05,
Variance: 0.006,
DarkFraction: 0.94,
EdgeDensity: 0.030,
Diff: 0.02,
}
}
// darkSceneFrame is the trap: a night exterior is dark and flat but carries none of the fine
// detail text does.
func darkSceneFrame(position time.Duration) frameStats {
return frameStats{
PositionMs: position.Milliseconds(),
Mean: 0.08,
Variance: 0.010,
DarkFraction: 0.88,
EdgeDensity: 0.004,
Diff: 0.05,
}
}
func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats {
frames := make([]frameStats, 0, len(kinds))
for index, build := range kinds {
frames = append(frames, build(start+time.Duration(index)*coarseInterval))
}
return frames
}
func repeatFrames(count int, build func(time.Duration) frameStats) []func(time.Duration) frameStats {
out := make([]func(time.Duration) frameStats, 0, count)
for index := 0; index < count; index++ {
out = append(out, build)
}
return out
}
func TestFindsACleanTransition(t *testing.T) {
// Two minutes of programme, then two minutes of credits, at one frame every four seconds.
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, creditsFrame)...)
frames := window(39*time.Minute, kinds...)
index, separation, found := findTransition(frames, coarseInterval)
if !found {
t.Fatal("a clean transition was not found")
}
if index != 30 {
t.Fatalf("transition at frame %d, want 30", index)
}
if separation < minSeparation {
t.Fatalf("separation %.3f below the threshold %.3f", separation, minSeparation)
}
if score := visualConfidence(separation); score < ConfidenceThreshold {
t.Fatalf("confidence %.2f below the threshold", score)
}
}
// The whole point of not answering on darkness alone.
func TestDarkSceneIsNotCredits(t *testing.T) {
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...)
frames := window(39*time.Minute, kinds...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("a dark night scene was reported as credits")
}
}
func TestNoTransitionInOrdinaryProgramme(t *testing.T) {
frames := window(39*time.Minute, repeatFrames(60, programmeFrame)...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("found a transition in a window with no transition in it")
}
}
// A window that started too late shows credits from its first frame, which is evidence about
// the window rather than about the file.
func TestCreditsFromTheFirstFrameAreNotATransition(t *testing.T) {
frames := window(41*time.Minute, repeatFrames(50, creditsFrame)...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("an all-credits window was read as a transition")
}
}
// A dark beat at the end of an act is not sustained; a credits roll is.
func TestBriefDarkBeatIsIgnored(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(3, creditsFrame)...)
kinds = append(kinds, repeatFrames(30, programmeFrame)...)
frames := window(39*time.Minute, kinds...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("a three-frame dark beat was reported as credits")
}
}
func TestTooFewFramesAnswerNothing(t *testing.T) {
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("four frames were enough to claim a transition")
}
if _, _, found := findTransition(nil, coarseInterval); found {
t.Fatal("no frames produced a transition")
}
}
func TestCreditScoreSeparatesTheThreeCases(t *testing.T) {
credits := creditScore(creditsFrame(0))
dark := creditScore(darkSceneFrame(0))
programme := creditScore(programmeFrame(0))
if credits <= dark || dark <= programme {
t.Fatalf("scores do not separate: credits %.2f, dark scene %.2f, programme %.2f",
credits, dark, programme)
}
if credits < creditLikeFloor {
t.Fatalf("a textbook credits frame scored %.2f, below the floor %.2f",
credits, creditLikeFloor)
}
if dark >= creditLikeFloor {
t.Fatalf("a dark scene scored %.2f, at or above the credits floor %.2f",
dark, creditLikeFloor)
}
}
// Confidence from one detector agreeing with itself is not corroboration.
func TestVisualConfidenceIsCapped(t *testing.T) {
if score := visualConfidence(10); score >= 1 {
t.Fatalf("visual confidence reached %.2f", score)
}
if score := visualConfidence(minSeparation); score < ConfidenceThreshold {
t.Fatalf("a detection at the separation threshold scored %.2f, below the bar", score)
}
}