2026-08-15 09:23:26 +12:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 21:20:55 +12:00
|
|
|
// A dim, textured final scene: close enough to the roll's structural score that what comes
|
|
|
|
|
// after it could lift the sustain average, but visibly still programme.
|
|
|
|
|
func dimTexturedSceneFrame(position time.Duration) frameStats {
|
|
|
|
|
return frameStats{
|
|
|
|
|
PositionMs: position.Milliseconds(),
|
|
|
|
|
Mean: 0.13,
|
|
|
|
|
Variance: 0.006,
|
|
|
|
|
DarkFraction: 0.78,
|
|
|
|
|
EdgeDensity: 0.015,
|
|
|
|
|
Diff: 0.08,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 21:20:55 +12:00
|
|
|
// Television rolls are commonly only half a minute long. This is also the amount of media
|
|
|
|
|
// available after a correctly centred transition in the fine pass, so requiring more would
|
|
|
|
|
// make the refinement structurally unable to confirm the coarse answer.
|
|
|
|
|
func TestFindsACompactTelevisionCreditsRoll(t *testing.T) {
|
|
|
|
|
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
|
|
|
|
|
frames := window(20*time.Minute, kinds...)
|
|
|
|
|
|
|
|
|
|
if index, _, found := findTransition(frames, coarseInterval); !found || index != 20 {
|
|
|
|
|
t.Fatalf("compact credits transition = %d, found %t; want frame 20", index, found)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFinePassCanConfirmACentredTransition(t *testing.T) {
|
|
|
|
|
frames := make([]frameStats, 0, 80)
|
|
|
|
|
for index := 0; index < 40; index++ {
|
|
|
|
|
frames = append(frames, programmeFrame(time.Duration(index)*fineInterval))
|
|
|
|
|
}
|
|
|
|
|
for index := 40; index < 80; index++ {
|
|
|
|
|
frames = append(frames, creditsFrame(time.Duration(index)*fineInterval))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if index, _, found := findTransition(frames, fineInterval); !found || index != 40 {
|
|
|
|
|
t.Fatalf("fine transition = %d, found %t; want frame 40", index, found)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 21:20:55 +12:00
|
|
|
// A locally convincing title-like sequence in the middle of the tail is not closing
|
|
|
|
|
// credits when ordinary programme resumes after it. This is the real-media failure shape:
|
|
|
|
|
// looking only at the immediate sustain window marked Westworld and Blue Bloods several
|
|
|
|
|
// minutes before their genuine rolls.
|
|
|
|
|
func TestCreditLikeSequenceThatDoesNotReachTheEndIsIgnored(t *testing.T) {
|
|
|
|
|
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
|
|
|
|
|
kinds = append(kinds, repeatFrames(20, programmeFrame)...)
|
|
|
|
|
frames := window(35*time.Minute, kinds...)
|
|
|
|
|
|
|
|
|
|
if _, _, found := findTransition(frames, coarseInterval); found {
|
|
|
|
|
t.Fatal("a temporary credit-like sequence was reported as closing credits")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDimFinalSceneBeforeCreditsDoesNotMoveTheMarkerEarly(t *testing.T) {
|
|
|
|
|
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, dimTexturedSceneFrame)...)
|
|
|
|
|
kinds = append(kinds, repeatFrames(12, creditsFrame)...)
|
|
|
|
|
frames := window(35*time.Minute, kinds...)
|
|
|
|
|
|
|
|
|
|
index, _, found := findTransition(frames, coarseInterval)
|
|
|
|
|
if !found {
|
|
|
|
|
t.Fatal("the genuine credits after the dim scene were not found")
|
|
|
|
|
}
|
|
|
|
|
if index != 28 {
|
|
|
|
|
t.Fatalf("transition at frame %d, want the credits at frame 28", index)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 21:20:55 +12:00
|
|
|
func TestDenseCreditsStillLookLikeCredits(t *testing.T) {
|
|
|
|
|
frame := creditsFrame(0)
|
|
|
|
|
frame.EdgeDensity = 0.075
|
|
|
|
|
if score := creditScore(frame); score < creditLikeFloor {
|
|
|
|
|
t.Fatalf("dense credits scored %.2f, below the floor %.2f", score, creditLikeFloor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-15 22:26:17 +12:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|