174 lines
6.2 KiB
Go
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)
|
||
|
|
}
|