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) } }