Skip to content

Commit

Permalink
clipping: add methods to enable clip HLS manifests
Browse files Browse the repository at this point in the history
ClipManifest takes a manifest and the start/end clip times and returns
MediaSegments that correspond to those times.
  • Loading branch information
emranemran committed Aug 7, 2023
1 parent 5662003 commit 93c93ca
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 0 deletions.
90 changes: 90 additions & 0 deletions video/clip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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
}

Check warning on line 12 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L11-L12

Added lines #L11 - L12 were not covered by tests
var totalDuration float64
var segmentCount uint64
for _, segment := range manifest.Segments {
if segment == nil {
break
}
totalDuration += segment.Duration
segmentCount++
}
return totalDuration, segmentCount
}

// 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

Check warning on line 32 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L32

Added line #L32 was not covered by tests
}
// Skip any segments where duration is 0s
if segment.Duration <= 0.0 {
return 0, fmt.Errorf("error clipping: found 0s duration segments")
}

Check warning on line 37 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L36-L37

Added lines #L36 - L37 were not covered by tests
// 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)

Check warning on line 49 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L49

Added line #L49 was not covered by tests
}

// 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")
}

Check warning on line 67 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L66-L67

Added lines #L66 - L67 were not covered by tests
}

// 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")
}

Check warning on line 77 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L76-L77

Added lines #L76 - L77 were not covered by tests
}

// 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")
}

Check warning on line 85 in video/clip.go

View check run for this annotation

Codecov / codecov/patch

video/clip.go#L84-L85

Added lines #L84 - L85 were not covered by tests

log.Log(requestID, "Clipping segments", "from", startSegIdx, "to", endSegIdx)

return relevantSegments, nil
}
108 changes: 108 additions & 0 deletions video/clip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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)
require.NoError(t, err)
require.Equal(t, uint64(0), uint64(segs[0].SeqId))
require.Equal(t, 1, len(segs))

// start/end falls in different segments: ensure only 0.ts and 1.ts is returned
segs, err = ClipManifest("1234", plA, 1, 10.5)
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, len(segs))

// start/end with millisecond precision: ensure 0.ts and 1.ts is returned
segs, err = ClipManifest("1234", plA, 10.416, 10.5)
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, len(segs))

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)
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, len(segs))

// 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")

// end exceeds the duration of playlist: ensure only 0.ts is returned
segs, err = ClipManifest("1234", plB, 0, 20.78)
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, len(segs))
}

0 comments on commit 93c93ca

Please sign in to comment.