Files
2026-08-15 09:23:26 +12:00

174 lines
6.2 KiB
Go

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