315 lines
10 KiB
Go
315 lines
10 KiB
Go
package subsync
|
|
|
|
import (
|
|
"errors"
|
|
"math/rand"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where
|
|
// nothing is said. Regular cues would make every shift correlate with every other and
|
|
// prove nothing about the search.
|
|
func dialogue(count int, seed int64) []Cue {
|
|
rng := rand.New(rand.NewSource(seed))
|
|
cues := make([]Cue, 0, count)
|
|
at := 12 * time.Second
|
|
for range count {
|
|
length := time.Duration(900+rng.Intn(2600)) * time.Millisecond
|
|
cues = append(cues, Cue{Start: at, End: at + length, Text: "line"})
|
|
gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond
|
|
if rng.Intn(11) == 0 {
|
|
gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue
|
|
}
|
|
at += length + gap
|
|
}
|
|
return cues
|
|
}
|
|
|
|
func TestAlignRecoversAKnownOffset(t *testing.T) {
|
|
reference := dialogue(400, 7)
|
|
for _, offset := range []time.Duration{
|
|
-42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond,
|
|
2 * time.Second, 11500 * time.Millisecond, 37 * time.Second,
|
|
} {
|
|
t.Run(offset.String(), func(t *testing.T) {
|
|
// The broken track is the reference pushed the wrong way, so the correction
|
|
// that fixes it is the opposite of what was applied.
|
|
broken := Shift(reference, -offset, 1)
|
|
|
|
got, err := Align(broken, reference, DefaultOptions())
|
|
if err != nil {
|
|
t.Fatalf("Align: %v", err)
|
|
}
|
|
if got.Scale != 1 {
|
|
t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale)
|
|
}
|
|
if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond {
|
|
t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The case a plain offset cannot fix: a track authored against one transfer and played
|
|
// against another runs further out as the film goes on.
|
|
func TestAlignRecoversAFrameRateStretch(t *testing.T) {
|
|
reference := dialogue(500, 11)
|
|
scale := 25.0 / 24
|
|
broken := Shift(reference, 0, 1/scale)
|
|
|
|
got, err := Align(broken, reference, DefaultOptions())
|
|
if err != nil {
|
|
t.Fatalf("Align: %v", err)
|
|
}
|
|
if got.Scale != scale {
|
|
t.Fatalf("scale = %v, want %v", got.Scale, scale)
|
|
}
|
|
|
|
// The real test is not the reported numbers but whether applying them lands the last
|
|
// cue where it belongs — a stretch that is right at the start and wrong at the end is
|
|
// exactly the fault being repaired.
|
|
fixed := Shift(broken, got.Offset, got.Scale)
|
|
drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs()
|
|
if drift > 200*time.Millisecond {
|
|
t.Fatalf("last cue is out by %v after correction", drift)
|
|
}
|
|
}
|
|
|
|
// Timing that survives a translation. Different languages break lines differently and
|
|
// their cues do not start on the same frame, so the alignment has to hold when the two
|
|
// tracks agree only roughly.
|
|
func TestAlignSurvivesATranslatedReference(t *testing.T) {
|
|
reference := dialogue(400, 3)
|
|
rng := rand.New(rand.NewSource(99))
|
|
translated := make([]Cue, 0, len(reference))
|
|
for i, cue := range reference {
|
|
if i%9 == 0 {
|
|
continue // a line the other track merged into its neighbour
|
|
}
|
|
jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond
|
|
translated = append(translated, Cue{
|
|
Start: cue.Start + jitter,
|
|
End: cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond,
|
|
Text: "ligne",
|
|
})
|
|
}
|
|
|
|
offset := -8 * time.Second
|
|
got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions())
|
|
if err != nil {
|
|
t.Fatalf("Align: %v", err)
|
|
}
|
|
if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond {
|
|
t.Fatalf("offset = %v, want %v", got.Offset, offset)
|
|
}
|
|
}
|
|
|
|
// Most of this package's job is producing no answer. A wrong correction is worse than
|
|
// none: the viewer is told it worked and has no way to know the file is now further out.
|
|
func TestAlignRefusesRatherThanGuess(t *testing.T) {
|
|
reference := dialogue(400, 5)
|
|
|
|
t.Run("a different film", func(t *testing.T) {
|
|
_, err := Align(dialogue(400, 6), reference, DefaultOptions())
|
|
var refusal *ErrNoAlignment
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("err = %v, want a refusal", err)
|
|
}
|
|
if refusal.Reason == "" {
|
|
t.Fatal("a refusal must carry something a viewer can read")
|
|
}
|
|
})
|
|
|
|
t.Run("too few lines to judge", func(t *testing.T) {
|
|
_, err := Align(dialogue(6, 1), reference, DefaultOptions())
|
|
var refusal *ErrNoAlignment
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("err = %v, want a refusal", err)
|
|
}
|
|
})
|
|
|
|
// Evenly spaced cues fit their own reference at every multiple of the spacing, so a
|
|
// dozen shifts score alike and none of them is the answer. This is the case the margin
|
|
// test exists for — the score alone is a perfect 1.0 and would sail through.
|
|
t.Run("timing that fits equally well in several places", func(t *testing.T) {
|
|
metronome := func(offset time.Duration) []Cue {
|
|
cues := make([]Cue, 0, 60)
|
|
for i := range 60 {
|
|
at := offset + time.Duration(i)*4*time.Second
|
|
cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"})
|
|
}
|
|
return cues
|
|
}
|
|
|
|
_, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions())
|
|
var refusal *ErrNoAlignment
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("err = %v, want a refusal", err)
|
|
}
|
|
if refusal.Score < 0.9 {
|
|
t.Fatalf("score = %.2f — this case must be refused on margin, not on score",
|
|
refusal.Score)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A sparse track is not by itself a bad one. Taking every seventeenth line of a correct
|
|
// subtitle leaves something that still lines up in exactly one place, and refusing it
|
|
// would cost the fix for a forced or hearing-impaired track that is merely displaced.
|
|
// What disqualifies a sparse track is being the *reference*, which Reference handles.
|
|
func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) {
|
|
reference := dialogue(400, 5)
|
|
sparse := []Cue{}
|
|
for i, cue := range reference {
|
|
if i%17 == 0 {
|
|
sparse = append(sparse, cue)
|
|
}
|
|
}
|
|
|
|
got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions())
|
|
if err != nil {
|
|
t.Fatalf("Align: %v", err)
|
|
}
|
|
if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond {
|
|
t.Fatalf("offset = %v, want -6s", got.Offset)
|
|
}
|
|
}
|
|
|
|
// A track already in sync must come back as no correction rather than a small nudge that
|
|
// makes the file different for no reason.
|
|
func TestAlignLeavesACorrectTrackAlone(t *testing.T) {
|
|
reference := dialogue(300, 21)
|
|
got, err := Align(reference, reference, DefaultOptions())
|
|
if err != nil {
|
|
t.Fatalf("Align: %v", err)
|
|
}
|
|
if got.Correction() {
|
|
t.Fatalf("a matching track reported a correction of %v", got)
|
|
}
|
|
}
|
|
|
|
func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) {
|
|
cues := []Cue{
|
|
{Start: time.Second, End: 3 * time.Second, Text: "first"},
|
|
{Start: 10 * time.Second, End: 12 * time.Second, Text: "second"},
|
|
}
|
|
got := Shift(cues, -30*time.Second, 1)
|
|
if len(got) != 2 {
|
|
t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got))
|
|
}
|
|
if got[0].Start < 0 || got[0].End < 0 {
|
|
t.Fatalf("negative timing survived: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
// Which track to measure against is the one choice this package cannot check, so the rules
|
|
// that make a track unusable as a yardstick are pinned.
|
|
func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) {
|
|
candidates := [][]Cue{
|
|
dialogue(30, 1), // 0: the broken one
|
|
dialogue(900, 2), // 1: forced, so unusable however long
|
|
dialogue(400, 3), // 2: the answer
|
|
dialogue(5, 4), // 3: too short to judge anything by
|
|
}
|
|
forced := []bool{false, true, false, false}
|
|
|
|
got, ok := Reference(candidates, 0, forced, 20)
|
|
if !ok || got != 2 {
|
|
t.Fatalf("reference = %d, ok = %v, want index 2", got, ok)
|
|
}
|
|
|
|
if _, ok := Reference(candidates[:1], 0, nil, 20); ok {
|
|
t.Fatal("a title whose only track is the broken one has no reference")
|
|
}
|
|
}
|
|
|
|
func TestParseReadsBothFormats(t *testing.T) {
|
|
srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" +
|
|
"2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n"
|
|
vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" +
|
|
"01:02.000 --> 01:04.250\nSecond line\nover two rows\n"
|
|
|
|
for name, data := range map[string]string{"srt": srt, "vtt": vtt} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cues, err := Parse([]byte(data))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(cues) != 2 {
|
|
t.Fatalf("cue count = %d, want 2", len(cues))
|
|
}
|
|
if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond {
|
|
t.Fatalf("first cue = %+v", cues[0])
|
|
}
|
|
if cues[1].Start != 62*time.Second {
|
|
t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start)
|
|
}
|
|
if cues[1].Text != "Second line\nover two rows" {
|
|
t.Fatalf("second cue text = %q", cues[1].Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) {
|
|
t.Run("byte order mark and CRLF", func(t *testing.T) {
|
|
data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n"
|
|
cues, err := Parse([]byte(data))
|
|
if err != nil || len(cues) != 1 || cues[0].Text != "Hi" {
|
|
t.Fatalf("cues = %+v, err = %v", cues, err)
|
|
}
|
|
})
|
|
|
|
t.Run("no blank line between cues", func(t *testing.T) {
|
|
data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n"
|
|
cues, err := Parse([]byte(data))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(cues) != 2 || cues[1].Start != 3*time.Second {
|
|
t.Fatalf("cues = %+v", cues)
|
|
}
|
|
})
|
|
|
|
t.Run("nothing that looks like a subtitle", func(t *testing.T) {
|
|
if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) {
|
|
t.Fatalf("err = %v, want ErrNoCues", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFormatSRTRoundTrips(t *testing.T) {
|
|
cues := []Cue{
|
|
{Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"},
|
|
{Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"},
|
|
}
|
|
out := FormatSRT(Normalise(cues))
|
|
if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") {
|
|
t.Fatalf("timestamps not written as SubRip:\n%s", out)
|
|
}
|
|
|
|
back, err := Parse(out)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" {
|
|
t.Fatalf("round trip changed the cues: %+v", back)
|
|
}
|
|
}
|
|
|
|
// The search runs while somebody is looking at a menu over their film, so its cost is part
|
|
// of whether the feature is usable at all.
|
|
func BenchmarkAlignFeatureLength(b *testing.B) {
|
|
reference := dialogue(1400, 1)
|
|
broken := Shift(reference, -9*time.Second, 1)
|
|
opts := DefaultOptions()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
if _, err := Align(broken, reference, opts); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|