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) } } // The onset walk-back, tested on scores directly. // // It is pure and it is the only thing in the detector that can move a marker *earlier*, so // its two bounds matter more than the movement itself: a hard cut must not move at all, and // a long ramp must not drag the marker back into the programme. // establishedCredits is the score a fully-established roll produces, near enough — the // literal matters only as the thing onsetFraction is measured against. const establishedCredits = 0.90 func TestBackOffWalksIntoTheFade(t *testing.T) { // Forty frames of programme, three of fade, then the roll. The split arrives at the first // fully-established frame; the fade before it is already the credits. scores := make([]float64, 0, 60) for range 40 { scores = append(scores, 0.02) } scores = append(scores, 0.40, 0.55, 0.75) for range 17 { scores = append(scores, establishedCredits) } onset := backOffToOnset(scores, 43, establishedCredits, fineInterval) if onset != 40 { t.Fatalf("onset at frame %d, want 40 (the first frame of the fade)", onset) } } // A cut straight from programme to credits has no fade to find, and the split was already // right. Moving it would be inventing a ramp that is not there. func TestBackOffStopsAtAHardCut(t *testing.T) { scores := make([]float64, 0, 60) for range 40 { scores = append(scores, 0.02) } for range 20 { scores = append(scores, establishedCredits) } if onset := backOffToOnset(scores, 40, establishedCredits, fineInterval); onset != 40 { t.Fatalf("a hard cut moved from 40 to %d", onset) } } // The bound is what stops a scene dimming gradually towards the credits pulling the marker // back through the last minute of the programme. func TestBackOffIsBounded(t *testing.T) { // Everything qualifies, so only the bound can stop the walk. scores := make([]float64, 80) for index := range scores { scores[index] = establishedCredits } onset := backOffToOnset(scores, 60, establishedCredits, fineInterval) limit := int(float64(onsetBackoffMs) / float64(fineInterval.Milliseconds())) if onset != 60-limit { t.Fatalf("walked back to %d, want %d (%d frames)", onset, 60-limit, limit) } if travelled := (60 - onset) * int(fineInterval.Milliseconds()); travelled > onsetBackoffMs { t.Fatalf("walked back %dms, past the %dms bound", travelled, onsetBackoffMs) } } // A frame that is dark but not yet mostly credit-like is still the programme. func TestBackOffRefusesAFrameBelowTheOnsetFraction(t *testing.T) { scores := []float64{0.02, 0.02, 0.10, establishedCredits, establishedCredits} // 0.10 is well under onsetFraction of the established level. if onset := backOffToOnset(scores, 3, establishedCredits, fineInterval); onset != 3 { t.Fatalf("a below-threshold frame was walked into: onset %d, want 3", onset) } } // Guarding the degenerate inputs rather than trusting callers, since this runs on whatever // the sampler produced. func TestBackOffHandlesDegenerateInput(t *testing.T) { scores := []float64{establishedCredits, establishedCredits} if onset := backOffToOnset(scores, 0, establishedCredits, fineInterval); onset != 0 { t.Fatalf("a split at zero moved to %d", onset) } if onset := backOffToOnset(scores, 1, 0, fineInterval); onset != 1 { t.Fatalf("an established level of zero moved the split to %d", onset) } }