From f9f932dcf5ee382888efd5788fb0b7094ebbfbbb Mon Sep 17 00:00:00 2001 From: Nick Brandt Date: Tue, 10 Sep 2024 02:55:13 -0400 Subject: [PATCH] fix: Add support for X-TIMESTAMP-MAP r/w persistence (#112) * Add support for X-TIMESTAMP-MAP r/w persistence * Refactor TimestampMap struct per review feedback * Fix MPEGTS / LOCAL ordering, update tests * Clean up timestamp map vtt test --- subtitles.go | 1 + webvtt.go | 60 +++++++++++++++++++++++++++++++++-------- webvtt_internal_test.go | 9 ++++--- webvtt_test.go | 12 +++++++-- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/subtitles.go b/subtitles.go index 6a58312..e917931 100644 --- a/subtitles.go +++ b/subtitles.go @@ -392,6 +392,7 @@ type Metadata struct { STLTranslatorName string Title string TTMLCopyright string + WebVTTTimestampMap *WebVTTTimestampMap } // Region represents a subtitle's region diff --git a/webvtt.go b/webvtt.go index c0d62b5..ec0e7e3 100644 --- a/webvtt.go +++ b/webvtt.go @@ -25,7 +25,7 @@ const ( webvttBlockNameText = "text" webvttDefaultStyleID = "astisub-webvtt-default-style-id" webvttTimeBoundariesSeparator = " --> " - webvttTimestampMap = "X-TIMESTAMP-MAP" + webvttTimestampMapHeader = "X-TIMESTAMP-MAP" ) // Vars @@ -44,11 +44,36 @@ func parseDurationWebVTT(i string) (time.Duration, error) { return parseDuration(i, ".", 3) } +// WebVTTTimestampMap is a structure for storing timestamps for WEBVTT's +// X-TIMESTAMP-MAP feature commonly used for syncing cue times with +// MPEG-TS streams. +type WebVTTTimestampMap struct { + Local time.Duration + MpegTS int64 +} + +// Offset calculates and returns the time offset described by the +// timestamp map. +func (t *WebVTTTimestampMap) Offset() time.Duration { + if t == nil { + return 0 + } + return time.Duration(t.MpegTS)*time.Second/90000 - t.Local +} + +// String implements Stringer interface for TimestampMap, returning +// the fully formatted header string for the instance. +func (t *WebVTTTimestampMap) String() string { + mpegts := fmt.Sprintf("MPEGTS:%d", t.MpegTS) + local := fmt.Sprintf("LOCAL:%s", formatDurationWebVTT(t.Local)) + return fmt.Sprintf("%s=%s,%s", webvttTimestampMapHeader, local, mpegts) +} + // https://tools.ietf.org/html/rfc8216#section-3.5 // Eg., `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000` => 10s // // `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000` => 2s -func parseTimestampMapWebVTT(line string) (timeOffset time.Duration, err error) { +func parseWebVTTTimestampMap(line string) (timestampMap *WebVTTTimestampMap, err error) { splits := strings.Split(line, "=") if len(splits) <= 1 { err = fmt.Errorf("astisub: invalid X-TIMESTAMP-MAP, no '=' found") @@ -81,7 +106,10 @@ func parseTimestampMapWebVTT(line string) (timeOffset time.Duration, err error) } } - timeOffset = time.Duration(mpegts)*time.Second/90000 - local + timestampMap = &WebVTTTimestampMap{ + Local: local, + MpegTS: mpegts, + } return } @@ -110,7 +138,6 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { var blockName string var comments []string var index int - var timeOffset time.Duration var webVTTStyles *StyleAttributes for scanner.Scan() { @@ -256,17 +283,22 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { // Append item o.Items = append(o.Items, item) - case strings.HasPrefix(line, webvttTimestampMap): + case strings.HasPrefix(line, webvttTimestampMapHeader): if len(item.Lines) > 0 { err = errors.New("astisub: found timestamp map after processing subtitle items") return } - timeOffset, err = parseTimestampMapWebVTT(line) + var timestampMap *WebVTTTimestampMap + timestampMap, err = parseWebVTTTimestampMap(line) if err != nil { err = fmt.Errorf("astisub: parsing webvtt timestamp map failed: %w", err) return } + if o.Metadata == nil { + o.Metadata = new(Metadata) + } + o.Metadata.WebVTTTimestampMap = timestampMap // Text default: @@ -287,10 +319,6 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { } } } - - if timeOffset > 0 { - o.Add(timeOffset) - } return } @@ -440,7 +468,17 @@ func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) { // Add header var c []byte - c = append(c, []byte("WEBVTT\n\n")...) + c = append(c, []byte("WEBVTT")...) + + // Write X-TIMESTAMP-MAP if set + if s.Metadata != nil { + webVTTTimestampMap := s.Metadata.WebVTTTimestampMap + if webVTTTimestampMap != nil { + c = append(c, []byte("\n")...) + c = append(c, []byte(webVTTTimestampMap.String())...) + } + } + c = append(c, []byte("\n\n")...) var style []string for _, s := range s.Styles { diff --git a/webvtt_internal_test.go b/webvtt_internal_test.go index 495f5d7..b33466c 100644 --- a/webvtt_internal_test.go +++ b/webvtt_internal_test.go @@ -74,11 +74,11 @@ func TestTimestampMap(t *testing.T) { expectError bool }{ { - line: "X-TIMESTAMP-MAP=MPEGTS:180000, LOCAL:00:00:00.000", + line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000", expectedOffset: 2 * time.Second, }, { - line: "X-TIMESTAMP-MAP=MPEGTS:180000, LOCAL:00:00:00.500", + line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.500,MPEGTS:180000", expectedOffset: 1500 * time.Millisecond, }, { @@ -107,12 +107,13 @@ func TestTimestampMap(t *testing.T) { }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { - offset, err := parseTimestampMapWebVTT(c.line) - assert.Equal(t, c.expectedOffset, offset) + timestampMap, err := parseWebVTTTimestampMap(c.line) + assert.Equal(t, c.expectedOffset, timestampMap.Offset()) if c.expectError { assert.Error(t, err) } else { assert.NoError(t, err) + assert.Equal(t, c.line, timestampMap.String()) } }) } diff --git a/webvtt_test.go b/webvtt_test.go index b1b5b7c..009d6b4 100644 --- a/webvtt_test.go +++ b/webvtt_test.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "strings" "testing" + "time" "github.com/asticode/go-astisub" "github.com/stretchr/testify/assert" @@ -117,17 +118,24 @@ func TestWebVTTWithTimestampMap(t *testing.T) { assert.Len(t, s.Items, 2) + assert.Equal(t, s.Items[0].StartAt.Milliseconds(), int64(933)) + assert.Equal(t, s.Items[0].EndAt.Milliseconds(), int64(2366)) + assert.Equal(t, s.Items[1].StartAt.Milliseconds(), int64(2400)) + assert.Equal(t, s.Items[1].EndAt.Milliseconds(), int64(3633)) + assert.Equal(t, s.Metadata.WebVTTTimestampMap.Offset(), time.Duration(time.Second*2)) + b := &bytes.Buffer{} err = s.WriteToWebVTT(b) assert.NoError(t, err) assert.Equal(t, `WEBVTT +X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000 1 -00:00:02.933 --> 00:00:04.366 +00:00:00.933 --> 00:00:02.366 ♪ ♪ 2 -00:00:04.400 --> 00:00:05.633 +00:00:02.400 --> 00:00:03.633 Evening. `, b.String()) }