From 30bf07d87090e90ebd3bd868faa16109a4cdd532 Mon Sep 17 00:00:00 2001 From: emranemran Date: Tue, 25 Jul 2023 18:55:25 -0700 Subject: [PATCH] clipping: add methods to enable clip HLS manifests ClipManifest takes a manifest and the start/end clip times and returns MediaSegments that correspond to those times. --- video/clip.go | 87 ++++++++++++++++++++++++++++++++++ video/clip_test.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 video/clip.go create mode 100644 video/clip_test.go diff --git a/video/clip.go b/video/clip.go new file mode 100644 index 000000000..e5e77b94b --- /dev/null +++ b/video/clip.go @@ -0,0 +1,87 @@ +package video + +import ( + "fmt" + "github.com/grafov/m3u8" + "github.com/livepeer/catalyst-api/log" +) + +func getTotalDurationAndSegments(manifest *m3u8.MediaPlaylist) (float64, uint64) { + if manifest == nil { + return 0.0, 0 + } + + var totalDuration float64 + allSegments := manifest.GetAllSegments() + for _, segment := range allSegments { + totalDuration += segment.Duration + } + return totalDuration, uint64(len(allSegments)) +} + +// Finds the segment in an HLS manifest that contains the timestamp (aka playhead) specified. +func getRelevantSegment(allSegments []*m3u8.MediaSegment, playHeadTime float64, startingIdx uint64) (uint64, error) { + playHeadDiff := 0.0 + + for _, segment := range allSegments[startingIdx:] { + // Break if we reach the end of the MediaSegment slice that contains all segments in the manifest + if segment == nil { + break + } + // Skip any segments where duration is 0s + if segment.Duration <= 0.0 { + return 0, fmt.Errorf("error clipping: found 0s duration segments") + } + // Check if the playhead is within the current segment and skip to + // the next segment if it's not. Also update the play head by referencing + // the starting time of the next segment. + playHeadDiff = playHeadTime - segment.Duration + if playHeadDiff > 0.0 { + playHeadTime = playHeadDiff + continue + } + // If we reach here, then we've found the relevant segment that falls within the playHeadTime + return segment.SeqId, nil + } + return 0, fmt.Errorf("error clipping: did not find a segment that falls within %v seconds", playHeadTime) +} + +// Function to find relevant segments that span from the clipping start and end times +func ClipManifest(requestID string, manifest *m3u8.MediaPlaylist, startTime, endTime float64) ([]*m3u8.MediaSegment, error) { + var startSegIdx, endSegIdx uint64 + var err error + + manifestDuration, manifestSegments := getTotalDurationAndSegments(manifest) + + // Find the segment index that correlates with the specified startTime + // but error out it exceeds the manifest's duration. + if startTime > manifestDuration { + return nil, fmt.Errorf("error clipping: start time specified exceeds duration of manifest") + } else { + startSegIdx, err = getRelevantSegment(manifest.Segments, startTime, 0) + if err != nil { + return nil, fmt.Errorf("error clipping: failed to get a starting index") + } + } + + // Find the segment index that correlates with the specified endTime. + if endTime > manifestDuration { + endSegIdx = manifestSegments - 1 + } else { + endSegIdx, err = getRelevantSegment(manifest.Segments, endTime, startSegIdx) + if err != nil { + return nil, fmt.Errorf("error clipping: failed to get an ending index") + } + } + + // Generate a slice of all segments that overlap with startTime to endTime + relevantSegments := manifest.Segments[startSegIdx : endSegIdx+1] + totalRelSegs := len(relevantSegments) + if totalRelSegs == 0 { + return nil, fmt.Errorf("error clipping: no relevant segments found in the specified time range") + } + + log.Log(requestID, "Clipping segments", "from", startSegIdx, "to", endSegIdx) + + return relevantSegments, nil +} diff --git a/video/clip_test.go b/video/clip_test.go new file mode 100644 index 000000000..ab3a8bc84 --- /dev/null +++ b/video/clip_test.go @@ -0,0 +1,114 @@ +package video + +import ( + "strings" + "testing" + + "github.com/grafov/m3u8" + "github.com/stretchr/testify/require" +) + +const manifestA = `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10.4160000000, +0.ts +#EXTINF:5.3340000000, +5000.ts +#EXT-X-ENDLIST` + +const manifestB = `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:EVENT +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2023-06-06T00:27:38.157Z +#EXTINF:5.780, +0.ts +#EXT-X-PROGRAM-DATE-TIME:2023-06-06T00:27:43.937Z +#EXTINF:6.000, +1.ts +#EXT-X-PROGRAM-DATE-TIME:2023-06-06T00:27:49.937Z +#EXTINF:6.000, +2.ts +#EXT-X-PROGRAM-DATE-TIME:2023-06-06T00:27:55.937Z +#EXTINF:1.000, +3.ts +#EXT-X-ENDLIST` + +const manifestC = `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:EVENT +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:0 +0.ts +1.ts +2.ts +3.ts +#EXT-X-ENDLIST` + +func TestClippingFailsWhenInvalidManifestIsUsed(t *testing.T) { + + sourceManifestC, _, err := m3u8.DecodeFrom(strings.NewReader(manifestC), true) + require.NoError(t, err) + plC := sourceManifestC.(*m3u8.MediaPlaylist) + + _, err = ClipManifest("1234", plC, 1, 5) + require.ErrorContains(t, err, "error clipping") +} + +func TestClippingSucceedsWhenValidManifestIsUsed(t *testing.T) { + sourceManifestA, _, err := m3u8.DecodeFrom(strings.NewReader(manifestA), true) + require.NoError(t, err) + plA := sourceManifestA.(*m3u8.MediaPlaylist) + + // start/end falls in same segment: ensure only 0.ts is returned + segs, err := ClipManifest("1234", plA, 1, 5) + length := len(segs) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(segs[0].SeqId)) + require.Equal(t, 1, length) + + // start/end falls in different segments: ensure only 0.ts and 1.ts is returned + segs, err = ClipManifest("1234", plA, 1, 10.5) + length = len(segs) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(segs[0].SeqId)) + require.Equal(t, uint64(1), uint64(segs[1].SeqId)) + require.Equal(t, 2, length) + + // start/end with millisecond precision: ensure 0.ts and 1.ts is returned + segs, err = ClipManifest("1234", plA, 10.416, 10.5) + length = len(segs) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(segs[0].SeqId)) + require.Equal(t, uint64(1), uint64(segs[1].SeqId)) + require.Equal(t, 2, length) + + sourceManifestB, _, err := m3u8.DecodeFrom(strings.NewReader(manifestB), true) + require.NoError(t, err) + plB := sourceManifestB.(*m3u8.MediaPlaylist) + + // start/end spans the full duration of playlist: ensure 0.ts and 3.ts is returned + segs, err = ClipManifest("1234", plB, 0, 18.78) + length = len(segs) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(segs[0].SeqId)) + require.Equal(t, uint64(3), uint64(segs[3].SeqId)) + require.Equal(t, 4, length) + + // start exceeds the duration of playlist: ensure no segments are returned + segs, err = ClipManifest("1234", plB, 30, 20.78) + require.ErrorContains(t, err, "start time specified exceeds duration of manifest") + require.Equal(t, segs, []*m3u8.MediaSegment(nil)) + + // end exceeds the duration of playlist: ensure only 0.ts is returned + segs, err = ClipManifest("1234", plB, 0, 20.78) + length = len(segs) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(segs[0].SeqId)) + require.Equal(t, uint64(3), uint64(segs[3].SeqId)) + require.Equal(t, 4, length) +}