-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
00c50be
commit af242d1
Showing
2 changed files
with
194 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package video | ||
|
||
import ( | ||
"fmt" | ||
"github.com/grafov/m3u8" | ||
"github.com/livepeer/catalyst-api/log" | ||
) | ||
|
||
// Function to calculate the total duration of the HLS manifest | ||
func calculateTotalDuration(manifest *m3u8.MediaPlaylist) float64 { | ||
if manifest == nil { | ||
return 0.0 | ||
} | ||
|
||
totalDuration := 0.0 | ||
for _, segment := range manifest.Segments { | ||
if segment == nil { | ||
break | ||
} | ||
totalDuration += segment.Duration | ||
} | ||
return totalDuration | ||
} | ||
|
||
// Checks if a given segment already exists in a slice of MediaSegments. Mainly used to ensure | ||
// we don't add duplicate segments when the playhead start/stop time falls in the same segment. | ||
func segmentExists(relSegs []*m3u8.MediaSegment, seg *m3u8.MediaSegment) bool { | ||
for _, s := range relSegs { | ||
if s.SeqId == seg.SeqId { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
// Finds the segment in an HLS manifest that contains the timestamp (aka playhead) specified. | ||
func getRelevantSegment(allSegments []*m3u8.MediaSegment, relevantSegments *[]*m3u8.MediaSegment, playHeadTime float64, startingIdx uint64) (segNum uint64, err error) { | ||
playHeadDiff := 0.0 | ||
segNum = 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") | ||
continue | ||
} | ||
// 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 | ||
} | ||
// Skip adding to slice of relevantSegments if the segment already exists | ||
// (e.g. start and end times fall within same segment index) | ||
if !segmentExists(*relevantSegments, segment) { | ||
*relevantSegments = append(*relevantSegments, segment) | ||
segNum = segment.SeqId | ||
} | ||
break | ||
} | ||
return segNum, nil | ||
} | ||
|
||
// Function to find relevant segments corresponding to the clipping start and end times | ||
func ClipManifest(requestID string, manifest *m3u8.MediaPlaylist, startTime, endTime float64) ([]*m3u8.MediaSegment, error) { | ||
relevantSegments := make([]*m3u8.MediaSegment, 0) | ||
|
||
startSegIdx, err := getRelevantSegment(manifest.Segments, &relevantSegments, startTime, 0) | ||
if err != nil { | ||
return nil, fmt.Errorf("error clipping: failed to get a starting index") | ||
} | ||
endSegIdx, err := getRelevantSegment(manifest.Segments, &relevantSegments, endTime, startSegIdx) | ||
if err != nil { | ||
return nil, fmt.Errorf("error clipping: failed to get an ending index") | ||
} | ||
|
||
if len(relevantSegments) == 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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
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) | ||
|
||
// start/end falls in same segment: ensure only 0.ts is returned | ||
_, err = ClipManifest("1234", plC, 1, 5) | ||
require.ErrorContains(t, err, "no relevant segments found") | ||
} | ||
|
||
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[1].SeqId)) | ||
require.Equal(t, 2, len(segs)) | ||
|
||
// 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[1].SeqId)) | ||
require.Equal(t, 1, len(segs)) | ||
} |