package credits import ( "sort" "time" ) // Where the scan looks, and how it is narrowed. // // This is the file that decides how much of a media file is ever read, so it is where the // performance claim of the whole subsystem is either kept or given away. A generic tail // window is a fallback; the interesting case is the second episode of a season onwards, // where the first one has already told us where this show puts its credits. // Provenance of a window, reported by the benchmark so "is demand-driven narrowing actually // saving work" is a question with a printed answer rather than an opinion. const ( WindowGeneric = "generic-tail-window" WindowSeason = "season-history" WindowBehaviour = "tracearr-behaviour" ) const ( // The generic tail, when nothing is known: a fifth of the runtime, floored at five // minutes so a short episode still has somewhere to look and capped at twelve so a // three-hour film does not turn into a thirty-six minute scan. genericTailFraction = 0.20 genericTailMinimum = 5 * time.Minute genericTailMaximum = 12 * time.Minute // The margin either side of an expected position. Wide enough to absorb a cold open // that ran long or a differently-cut episode, narrow enough that the scan is a couple of // minutes rather than ten. narrowMargin = 90 * time.Second // seasonSpread is how much disagreement between known episodes is tolerated before the // season stops being evidence. A show that puts its credits at a consistent point is // usable; one whose known markers are three minutes apart is telling us the episodes are // not structurally alike, and narrowing on their average would look in the wrong place. seasonSpread = 2 * time.Minute // seasonMinimumSamples is how many known episodes it takes. One is an anecdote — it may // itself be the mis-detection — and narrowing a scan onto a single unconfirmed reading is // how one wrong marker propagates through a whole season. seasonMinimumSamples = 2 ) // ScanWindow is a span of the file, in milliseconds from its start. type ScanWindow struct { StartMs int64 EndMs int64 Source string } func (w ScanWindow) DurationMs() int64 { if w.EndMs <= w.StartMs { return 0 } return w.EndMs - w.StartMs } func (w ScanWindow) Valid() bool { return w.DurationMs() > 0 } // GenericTailWindow is where to look when nothing at all is known about the show. func GenericTailWindow(runtimeMs int64) ScanWindow { if runtimeMs <= 0 { return ScanWindow{} } tail := int64(float64(runtimeMs) * genericTailFraction) if minimum := genericTailMinimum.Milliseconds(); tail < minimum { tail = minimum } if maximum := genericTailMaximum.Milliseconds(); tail > maximum { tail = maximum } start := runtimeMs - tail if start < 0 { start = 0 } return ScanWindow{StartMs: start, EndMs: runtimeMs, Source: WindowGeneric} } // aroundWindow is a narrow span centred on an expected position, clamped to the file. func aroundWindow(runtimeMs, expectedMs int64, source string) ScanWindow { margin := narrowMargin.Milliseconds() start := expectedMs - margin if start < 0 { start = 0 } end := expectedMs + margin if runtimeMs > 0 && end > runtimeMs { end = runtimeMs } if end <= start { return ScanWindow{} } return ScanWindow{StartMs: start, EndMs: end, Source: source} } // seasonExpectation is where this show's credits are expected to start, learned from // episodes of the same season that have already been decided. // // Measured as a *tail offset* — how long the credits run for — rather than as an absolute // position, which is the one modelling decision in this file worth defending. Episodes of a // season vary in length by a minute or two; the credits sequence itself does not vary at all, // because it is the same sequence. Averaging absolute positions would smear that variation // into the estimate and force a wider margin to cover it. func seasonExpectation(runtimeMs int64, history []Marker, runtimeOf func(Marker) int64) (int64, bool) { if runtimeMs <= 0 || len(history) < seasonMinimumSamples { return 0, false } offsets := make([]int64, 0, len(history)) for _, marker := range history { episodeRuntime := runtimeOf(marker) if episodeRuntime <= 0 || marker.CreditsStartMs <= 0 || marker.CreditsStartMs >= episodeRuntime { continue } // Only markers we would trust ourselves are allowed to steer a scan. A low-confidence // reading is exactly the one that should be re-examined, not the one that decides // where everything else looks. if marker.Confidence < ConfidenceThreshold { continue } offsets = append(offsets, episodeRuntime-marker.CreditsStartMs) } if len(offsets) < seasonMinimumSamples { return 0, false } sort.Slice(offsets, func(a, b int) bool { return offsets[a] < offsets[b] }) if offsets[len(offsets)-1]-offsets[0] > seasonSpread.Milliseconds() { return 0, false } // The median, not the mean: one episode with a long "next time on" trailer after the // credits must not drag the estimate, and with two samples the median of the sorted pair // is the lower — the safer end, since looking slightly early costs nothing but looking // late misses the transition entirely. median := offsets[len(offsets)/2] expected := runtimeMs - median if expected <= 0 || expected >= runtimeMs { return 0, false } return expected, true } // NarrowWindow decides where to look, preferring the strongest evidence available. // // Order matters and is deliberate. Behaviour first: where enough viewers stopped is a direct // observation of this exact episode, where season history is an inference from its // neighbours. Season history second. The generic tail last, and only because something has // to be. func NarrowWindow( runtimeMs int64, history []Marker, runtimeOf func(Marker) int64, behaviour BehaviourEvidence, ) ScanWindow { if runtimeMs <= 0 { return ScanWindow{} } if behaviour.Usable() { if window := aroundWindow(runtimeMs, behaviour.StartMs, WindowBehaviour); window.Valid() { return window } } if runtimeOf != nil { if expected, ok := seasonExpectation(runtimeMs, history, runtimeOf); ok { if window := aroundWindow(runtimeMs, expected, WindowSeason); window.Valid() { return window } } } return GenericTailWindow(runtimeMs) }