0.2.66 - End Credits improvements / Gateway: 0.1.47 - End credits redesign
This commit is contained in:
@@ -270,23 +270,18 @@ func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The toggle a television is told to obey has to be one this build knows.
|
||||
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
|
||||
definition, ok := preferenceDefinitionFor("speedUpCredits")
|
||||
if !ok {
|
||||
t.Fatal("speedUpCredits is missing from the preference catalogue")
|
||||
}
|
||||
if definition.Kind != preferenceToggle {
|
||||
t.Fatalf("kind = %v, want a toggle", definition.Kind)
|
||||
}
|
||||
if definition.Default != true {
|
||||
t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
|
||||
"is visible and reversible in a way an automatic seek is not", definition.Default)
|
||||
// The credits pane is not a viewer preference any more, and this pins the removal from both
|
||||
// ends: the key is gone from the catalogue, and a document still carrying it — written by a
|
||||
// television or an operator before the removal — has it dropped rather than round-tripped.
|
||||
// Without the second half a stored `false` would survive every sync and quietly keep the
|
||||
// feature off on the one set that had turned it off.
|
||||
func TestSpeedUpCreditsIsNoLongerAPreference(t *testing.T) {
|
||||
if _, ok := preferenceDefinitionFor("speedUpCredits"); ok {
|
||||
t.Fatal("speedUpCredits is still in the preference catalogue")
|
||||
}
|
||||
|
||||
// An illegal value must come back as the default rather than reaching a player.
|
||||
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
|
||||
if normalised["speedUpCredits"] != true {
|
||||
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
|
||||
normalised := normalizePreferences(map[string]any{"speedUpCredits": false})
|
||||
if _, present := normalised["speedUpCredits"]; present {
|
||||
t.Fatal("a stored speedUpCredits must be dropped, not carried forward")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,17 +163,15 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
option(skipIntroOff, "Do nothing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Default on: the whole feature is that it happens without being asked for, and it
|
||||
// is visible, reversible and over in a minute — a viewer who dislikes it turns it
|
||||
// off having seen exactly what it does. That is a different trade from
|
||||
// skipIntroMode's, which defaults to the button rather than the automatic seek
|
||||
// because a jump nobody can see coming is not recoverable by watching it.
|
||||
Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
|
||||
Description: "When an episode reaches its closing credits, shrink them to one side " +
|
||||
"at double speed and show what is on next beside them.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
// `speedUpCredits` was here. It is deliberately not a viewer preference any more: the
|
||||
// closing-credits pane is how this client ends an episode, and an opt-out made it a
|
||||
// feature half the household never saw. The operator's `end_credits` flag remains the
|
||||
// one switch, which is the right level for it — it governs a subsystem that reads media
|
||||
// bytes, and turning it off is a decision about the server rather than about taste.
|
||||
//
|
||||
// Removing the key from the catalogue is also how the stored values are cleaned up:
|
||||
// normalizePreferences drops what it does not recognise, so a viewer who had turned it
|
||||
// off gets the pane back on their next sync with nothing having to migrate anything.
|
||||
{
|
||||
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.45
|
||||
0.1.47
|
||||
|
||||
@@ -38,6 +38,27 @@ const (
|
||||
// primary guard against answering on noise, and it is set high because the cost of a
|
||||
// wrong marker is somebody losing the end of an episode.
|
||||
minSeparation = 0.18
|
||||
|
||||
// onsetFraction is how credit-like a frame has to be, relative to the established roll,
|
||||
// to count as already part of it.
|
||||
//
|
||||
// The split itself lands where the credits are *established*, because the sustain window
|
||||
// after it has to clear creditLikeFloor on average — and credits usually fade in, so the
|
||||
// first second or two of the fade scores below that floor and pushes the split forward.
|
||||
// Measured against real media the gap is two to three seconds, which is visible: the roll
|
||||
// has plainly begun before the picture moves.
|
||||
//
|
||||
// So the split is walked backwards through the fade to the first frame that is already
|
||||
// mostly credit-like. Two fifths is deliberately generous — this is looking for the start
|
||||
// of a ramp, not for more credits — and it is a fraction of the established level rather
|
||||
// than an absolute, because a roll over a bright background never reaches the same score
|
||||
// as one over black and would otherwise never be walked back at all.
|
||||
onsetFraction = 0.4
|
||||
|
||||
// onsetBackoffMs bounds that walk. A fade is a second or two; anything walking further is
|
||||
// no longer following one, and the bound is what stops a gradual dimming at the end of a
|
||||
// scene dragging the marker back into the programme.
|
||||
onsetBackoffMs = 4000
|
||||
)
|
||||
|
||||
// VisualDetector implements Detector over the ffmpeg sampler.
|
||||
@@ -195,7 +216,46 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
|
||||
}
|
||||
bestIndex, bestSeparation, found = split, separation, true
|
||||
}
|
||||
return bestIndex, bestSeparation, found
|
||||
if !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
// The separation is reported for the split that earned it, never for the walked-back
|
||||
// frame: confidence is a statement about how clearly the transition was found, and
|
||||
// recomputing it over a fade would report the answer as weaker for having been improved.
|
||||
return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval),
|
||||
bestSeparation, true
|
||||
}
|
||||
|
||||
// backOffToOnset walks a split backwards through the credits' fade-in.
|
||||
//
|
||||
// Pure, bounded, and it can only ever move the marker earlier — which is the direction that
|
||||
// needs the care, so both bounds matter: it stops at the first frame that is not already
|
||||
// mostly credit-like, and it never travels further than onsetBackoffMs. On a hard cut from
|
||||
// programme to credits the preceding frame scores near zero and the walk stops immediately,
|
||||
// which is correct: there is no fade to find and the split was already right.
|
||||
func backOffToOnset(scores []float64, split int, established float64, interval time.Duration) int {
|
||||
if split <= 0 || established <= 0 || interval <= 0 {
|
||||
return split
|
||||
}
|
||||
limit := int(float64(onsetBackoffMs) / float64(interval.Milliseconds()))
|
||||
if limit < 1 {
|
||||
// A sampling interval coarser than the whole allowance cannot resolve a fade at all.
|
||||
// The coarse pass is this case, and it is the one whose answer the fine pass replaces.
|
||||
return split
|
||||
}
|
||||
floor := onsetFraction * established
|
||||
earliest := split - limit
|
||||
if earliest < 0 {
|
||||
earliest = 0
|
||||
}
|
||||
onset := split
|
||||
for index := split - 1; index >= earliest; index-- {
|
||||
if scores[index] < floor {
|
||||
break
|
||||
}
|
||||
onset = index
|
||||
}
|
||||
return onset
|
||||
}
|
||||
|
||||
// visualConfidence maps separation onto a score.
|
||||
|
||||
@@ -160,3 +160,87 @@ func TestVisualConfidenceIsCapped(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,21 @@ type Task struct {
|
||||
// Status is one task as the console reads it: the declaration, the operator's overrides,
|
||||
// the last run and when the next one is due.
|
||||
type Status struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Group string `json:"group"`
|
||||
Interval int64 `json:"intervalSeconds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
NextRun *time.Time `json:"nextRun,omitempty"`
|
||||
LastRun *store.TaskRun `json:"lastRun,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Group string `json:"group"`
|
||||
Interval int64 `json:"intervalSeconds"`
|
||||
// DefaultInterval is the cadence declared in code, which Interval hides whenever an
|
||||
// operator has overridden it. Both are sent because the console cannot otherwise tell
|
||||
// "every ten minutes because that is the default" from "every ten minutes because
|
||||
// somebody chose it" — and without that distinction its cadence control has no way to
|
||||
// offer a way back, or to say that a task is no longer running as shipped.
|
||||
DefaultInterval int64 `json:"defaultIntervalSeconds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
NextRun *time.Time `json:"nextRun,omitempty"`
|
||||
LastRun *store.TaskRun `json:"lastRun,omitempty"`
|
||||
}
|
||||
|
||||
type registered struct {
|
||||
@@ -458,8 +464,9 @@ func (s *Scheduler) Snapshot() []Status {
|
||||
status := Status{
|
||||
ID: entry.task.ID, Name: entry.task.Name,
|
||||
Description: entry.task.Description, Group: entry.task.Group,
|
||||
Interval: int64(entry.effectiveInterval() / time.Second),
|
||||
Enabled: entry.enabled, Running: entry.running,
|
||||
Interval: int64(entry.effectiveInterval() / time.Second),
|
||||
DefaultInterval: int64(entry.task.Interval / time.Second),
|
||||
Enabled: entry.enabled, Running: entry.running,
|
||||
}
|
||||
if !entry.nextRun.IsZero() && entry.enabled {
|
||||
next := entry.nextRun
|
||||
|
||||
@@ -219,3 +219,44 @@ func waitForRun(t *testing.T, sched *Scheduler, id string) Status {
|
||||
}
|
||||
|
||||
var _ sync.Locker = (*sync.Mutex)(nil)
|
||||
|
||||
// The console needs both cadences to draw its control honestly: the one in force and the
|
||||
// one the code declares. Reporting only the effective interval made "every ten minutes
|
||||
// because that is the default" and "every ten minutes because somebody chose it" identical
|
||||
// on the wire, so nothing could offer a way back to the default or mark a task as no longer
|
||||
// running as shipped.
|
||||
func TestSnapshotReportsTheDeclaredCadenceBesideTheEffectiveOne(t *testing.T) {
|
||||
sched := quietScheduler()
|
||||
sched.Register(Task{ID: "credits", Name: "Credits", Interval: 10 * time.Minute, Run: noop})
|
||||
|
||||
before := sched.Snapshot()[0]
|
||||
if before.Interval != 600 || before.DefaultInterval != 600 {
|
||||
t.Fatalf("unoverridden task: interval %d, default %d, want 600 and 600",
|
||||
before.Interval, before.DefaultInterval)
|
||||
}
|
||||
|
||||
if err := sched.SetInterval(context.Background(), "credits", time.Hour); err != nil {
|
||||
t.Fatalf("SetInterval: %v", err)
|
||||
}
|
||||
after := sched.Snapshot()[0]
|
||||
if after.Interval != 3600 {
|
||||
t.Fatalf("effective interval %d, want 3600", after.Interval)
|
||||
}
|
||||
// The declared cadence must survive the override, or the way back is lost.
|
||||
if after.DefaultInterval != 600 {
|
||||
t.Fatalf("declared cadence %d, want 600 — an override must not overwrite it",
|
||||
after.DefaultInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// A task that declares no cadence at all runs only when somebody presses the button, and
|
||||
// the console has to be able to say so rather than printing "every 0 seconds".
|
||||
func TestATaskWithNoDeclaredCadenceReportsZeroForBoth(t *testing.T) {
|
||||
sched := quietScheduler()
|
||||
sched.Register(Task{ID: "manual", Name: "Manual", Run: noop})
|
||||
|
||||
status := sched.Snapshot()[0]
|
||||
if status.Interval != 0 || status.DefaultInterval != 0 {
|
||||
t.Fatalf("interval %d, default %d, want 0 and 0", status.Interval, status.DefaultInterval)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user