diff --git a/README.md b/README.md index d27a2430..4ed14cc4 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Then run ```sh > go mod tidy ``` + to fetch and install all dependencies. To build `dashfetcher` and `livesim2` you can use the `Makefile` like diff --git a/cmd/dashfetcher/app/fetcher.go b/cmd/dashfetcher/app/fetcher.go index 61645b40..e8adf00a 100644 --- a/cmd/dashfetcher/app/fetcher.go +++ b/cmd/dashfetcher/app/fetcher.go @@ -325,7 +325,7 @@ func AutoDir(rawMPDurl, outDir string) (string, error) { if outRange > len(uBaseParts) { break } - for i := 0; i < outRange; i++ { + for i := range outRange { if outParts[outStart+i] != uBaseParts[i] { match = false break diff --git a/cmd/livesim2/app/asset.go b/cmd/livesim2/app/asset.go index aa9feeff..c9534051 100644 --- a/cmd/livesim2/app/asset.go +++ b/cmd/livesim2/app/asset.go @@ -453,9 +453,31 @@ func (l lastSegInfo) availabilityTime(ato float64) float64 { return math.Round(float64(l.startTime+l.dur)/float64(l.timescale)) - ato } +func calculateK(segmentDuration uint64, mediaTimescale int, chunkDurS *float64) (*uint64, error) { + if chunkDurS == nil || *chunkDurS <= 0 { + return nil, nil + } + chunkDurInTimescale := *chunkDurS * float64(mediaTimescale) + if chunkDurInTimescale <= 0 { + return nil, nil + } + + // Validate that chunk duration is not greater than segment duration + segmentDurS := float64(segmentDuration) / float64(mediaTimescale) + if *chunkDurS > segmentDurS { + return nil, fmt.Errorf("chunk duration %.2fs must be less than or equal to segment duration %.2fs", *chunkDurS, segmentDurS) + } + + kVal := uint64(math.Round(float64(segmentDuration) / chunkDurInTimescale)) + if kVal > 1 { + return &kVal, nil + } + return nil, nil +} + // generateTimelineEntries generates timeline entries for the given representation. // If no segments are available, startNr and lsi.nr are set to -1. -func (a *asset) generateTimelineEntries(repID string, wt wrapTimes, atoMS int) segEntries { +func (a *asset) generateTimelineEntries(repID string, wt wrapTimes, atoMS int, explicitChunkDurS *float64) (segEntries, error) { rep := a.Reps[repID] segs := rep.Segments nrSegs := len(segs) @@ -497,14 +519,20 @@ func (a *asset) generateTimelineEntries(repID string, wt wrapTimes, atoMS int) s if wt.nowWraps < 0 { // no segment finished yet. Return an empty list and set startNr and lsi.nr = -1 se.startNr = -1 se.lsi.nr = -1 - return se + return se, nil } se.startNr = wt.startWraps*nrSegs + relStartIdx nowNr := wt.nowWraps*nrSegs + relNowIdx t := uint64(rep.duration()*wt.startWraps) + segs[relStartIdx].StartTime d := segs[relStartIdx].dur() - s := &m.S{T: Ptr(t), D: d} + + k, err := calculateK(d, rep.MediaTimescale, explicitChunkDurS) + if err != nil { + return se, err + } + + s := &m.S{T: Ptr(t), D: d, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: k}} lsi := lastSegInfo{ timescale: uint64(rep.MediaTimescale), startTime: t, @@ -522,18 +550,18 @@ func (a *asset) generateTimelineEntries(repID string, wt wrapTimes, atoMS int) s continue } d = seg.dur() - s = &m.S{D: d} + s = &m.S{D: d, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: k}} se.entries = append(se.entries, s) lsi.dur = d lsi.nr = nr } se.lsi = lsi - return se + return se, nil } // generateTimelineEntriesFromRef generates timeline entries for the given representation given reference. // This is based on sample duration and the type of media. -func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) segEntries { +func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string, explicitChunkDurS *float64) (segEntries, error) { rep := a.Reps[repID] nrSegs := 0 for _, rs := range refSE.entries { @@ -547,7 +575,7 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s } if refSE.startNr < 0 { - return se + return se, nil } sampleDur := uint64(rep.sampleDur()) @@ -562,7 +590,7 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s editListOffset := uint64(rep.EditListOffset) expectedTime := t // Track what the time should be without explicit T var s *m.S - + var k *uint64 for _, rs := range refSE.entries { refD := rs.D for j := 0; j <= rs.R; j++ { @@ -571,6 +599,12 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s d := nextT - t if s == nil { + var err error + k, err = calculateK(d, rep.MediaTimescale, explicitChunkDurS) + if err != nil { + return se, err + } + // First segment: apply editListOffset adjustment adjustedT := t adjustedD := d @@ -590,12 +624,18 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s } } - s = &m.S{T: m.Ptr(adjustedT), D: adjustedD} + s = &m.S{T: m.Ptr(adjustedT), D: adjustedD, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: k}} se.entries = append(se.entries, s) expectedTime = adjustedT + adjustedD // Update expected time after first segment } else { // Subsequent segments if s.D != d { + var err error + k, err = calculateK(d, rep.MediaTimescale, explicitChunkDurS) + if err != nil { + return se, err + } + // New segment with different duration adjustedT := t if editListOffset > 0 && t >= editListOffset { @@ -605,10 +645,10 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s // Only add explicit T if the time is not continuous if adjustedT == expectedTime { // Time is continuous, no need for explicit T - s = &m.S{D: d} + s = &m.S{D: d, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: k}} } else { // Time is discontinuous, need explicit T - s = &m.S{T: m.Ptr(adjustedT), D: d} + s = &m.S{T: m.Ptr(adjustedT), D: d, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: k}} } se.entries = append(se.entries, s) expectedTime = adjustedT + d @@ -620,7 +660,7 @@ func (a *asset) generateTimelineEntriesFromRef(refSE segEntries, repID string) s t = nextT } } - return se + return se, nil } func (a *asset) setReferenceRep() error { @@ -822,6 +862,7 @@ type RepData struct { ConstantSampleDuration *uint32 `json:"constantSampleDuration,omitempty"` // Non-zero if all samples have the same duration EditListOffset int64 `json:"editListOffset,omitempty"` PreEncrypted bool `json:"preEncrypted"` + ChunkDurSSRS *float64 `json:"chunkDurSSRS,omitempty"` // Low delay chunk duration in seconds mediaRegexp *regexp.Regexp `json:"-"` initSeg *mp4.InitSegment `json:"-"` initBytes []byte `json:"-"` diff --git a/cmd/livesim2/app/asset_test.go b/cmd/livesim2/app/asset_test.go index 657ce7cf..8c28a039 100644 --- a/cmd/livesim2/app/asset_test.go +++ b/cmd/livesim2/app/asset_test.go @@ -171,6 +171,103 @@ func TestAssetLookupForNameOverlap(t *testing.T) { require.Equal(t, "assets/testpic_2s_1", a.AssetPath) } +func TestCalculateK(t *testing.T) { + testCases := []struct { + description string + segmentDuration uint64 + mediaTimescale int + chunkDuration *float64 + expectedK *uint64 + expectedError string + }{ + { + description: "nil chunk duration", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: nil, + expectedK: nil, + }, + { + description: "zero chunk duration", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(0.0), + expectedK: nil, + }, + { + description: "negative chunk duration", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(-1.0), + expectedK: nil, + }, + { + description: "zero media timescale", + segmentDuration: 192000, + mediaTimescale: 0, + chunkDuration: Ptr(1.0), + expectedK: nil, + }, + { + description: "k=4", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(0.5), + expectedK: Ptr(uint64(4)), + }, + { + description: "k=1, returns nil", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(2.0), + expectedK: nil, + }, + { + description: "rounding up", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(0.57), // 3.5087... -> 4 + expectedK: Ptr(uint64(4)), + }, + { + description: "rounding down", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(0.58), // 3.448... -> 3 + expectedK: Ptr(uint64(3)), + }, + { + description: "chunk duration greater than segment duration", + segmentDuration: 192000, + mediaTimescale: 96000, + chunkDuration: Ptr(2.5), // 2.5s > 2.0s segment duration + expectedK: nil, + expectedError: "chunk duration 2.50s must be less than or equal to segment duration 2.00s", + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + gotK, err := calculateK(tc.segmentDuration, tc.mediaTimescale, tc.chunkDuration) + + if tc.expectedError != "" { + require.Error(t, err) + require.Equal(t, tc.expectedError, err.Error()) + require.Nil(t, gotK) + return + } + + require.NoError(t, err) + if tc.expectedK == nil { + require.Nil(t, gotK) + } else { + require.NotNil(t, gotK) + require.Equal(t, *tc.expectedK, *gotK) + } + }) + } +} + func copyDir(srcDir, dstDir string) error { if err := os.MkdirAll(dstDir, 0755); err != nil { return err diff --git a/cmd/livesim2/app/cmaf-ingester.go b/cmd/livesim2/app/cmaf-ingester.go index e1ae0b47..4bf0279b 100644 --- a/cmd/livesim2/app/cmaf-ingester.go +++ b/cmd/livesim2/app/cmaf-ingester.go @@ -522,16 +522,26 @@ func (c *cmafIngester) sendMediaSegments(ctx context.Context, nextSegNr, nowMS i atoMS := int(c.cfg.getAvailabilityTimeOffsetS() * 1000) for idx, rd := range c.repsData { var se segEntries + var err error // The first representation is used as reference for generating timeline entries if idx == 0 { - refSegEntries = c.asset.generateTimelineEntries(rd.repID, wTimes, atoMS) + refSegEntries, err = c.asset.generateTimelineEntries(rd.repID, wTimes, atoMS, nil) + if err != nil { + return err + } se = refSegEntries } else { switch rd.contentType { case "video", "text", "image": - se = c.asset.generateTimelineEntries(rd.repID, wTimes, atoMS) + se, err = c.asset.generateTimelineEntries(rd.repID, wTimes, atoMS, nil) + if err != nil { + return err + } case "audio": - se = c.asset.generateTimelineEntriesFromRef(refSegEntries, rd.repID) + se, err = c.asset.generateTimelineEntriesFromRef(refSegEntries, rd.repID, nil) + if err != nil { + return err + } default: return fmt.Errorf("unknown content type %s", rd.contentType) } diff --git a/cmd/livesim2/app/config.go b/cmd/livesim2/app/config.go index f09722bb..5e645e08 100644 --- a/cmd/livesim2/app/config.go +++ b/cmd/livesim2/app/config.go @@ -27,6 +27,7 @@ const ( defaultReqIntervalS = 24 * 3600 defaultAvailabilityStartTimeS = 0 defaultAvailabilityTimeComplete = true + defaultSSRFlag = false defaultTimeShiftBufferDepthS = 60 defaultStartNr = 0 timeShiftBufferDepthMarginS = 10 diff --git a/cmd/livesim2/app/configurl.go b/cmd/livesim2/app/configurl.go index 907b5527..321d628f 100644 --- a/cmd/livesim2/app/configurl.go +++ b/cmd/livesim2/app/configurl.go @@ -65,7 +65,9 @@ const ( ) const ( - UrlParamSchemeIdUri = "urn:mpeg:dash:urlparam:2014" + UrlParamSchemeIdUri = "urn:mpeg:dash:urlparam:2014" + SsrSchemeIdUri = "urn:mpeg:dash:ssr:2023" + AdaptationSetSwitchingSchemeIdUri = "urn:mpeg:dash:adaptation-set-switching:2016" ) type ResponseConfig struct { @@ -110,6 +112,9 @@ type ResponseConfig struct { SegStatusCodes []SegStatusCodes `json:"SegStatus,omitempty"` Traffic []LossItvls `json:"Traffic,omitempty"` Query *Query `json:"Query,omitempty"` + SSRFlag bool `json:"SSRFlag,omitempty"` + SSRAS string `json:"SSRAS,omitempty"` + ChunkDurSSR string `json:"ChunkDurSSR,omitempty"` } // SegStatusCodes configures regular extraordinary segment response codes @@ -241,6 +246,7 @@ func NewResponseConfig() *ResponseConfig { c := ResponseConfig{ StartTimeS: defaultAvailabilityStartTimeS, AvailabilityTimeCompleteFlag: defaultAvailabilityTimeComplete, + SSRFlag: defaultSSRFlag, TimeShiftBufferDepthS: Ptr(defaultTimeShiftBufferDepthS), StartNr: Ptr(defaultStartNr), TimeSubsDurMS: defaultTimeSubsDurMS, @@ -394,6 +400,11 @@ cfgLoop: } case "annexI": cfg.Query = sc.ParseQuery(key, val) + case "ssras": + cfg.SSRAS = val + cfg.SSRFlag = true + case "chunkdurssr": + cfg.ChunkDurSSR = val default: contentStartIdx = i break cfgLoop @@ -447,6 +458,10 @@ func verifyAndFillConfig(cfg *ResponseConfig, nowMS int) error { } // We do not check here that the drm is one that has been configured, // since pre-encrypted content will influence what is valid. + + if cfg.ChunkDurSSR != "" && cfg.SSRAS == "" { + return fmt.Errorf("chunkDurSSR requires ssrAS to be configured") + } return nil } diff --git a/cmd/livesim2/app/configurl_test.go b/cmd/livesim2/app/configurl_test.go index a227edd4..36b3b809 100644 --- a/cmd/livesim2/app/configurl_test.go +++ b/cmd/livesim2/app/configurl_test.go @@ -237,6 +237,31 @@ func TestProcessURLCfg(t *testing.T) { }, err: "", }, + { + url: "/livesim2/chunkdurssr_1,0.2/asset.mpd", + nowMS: 0, + contentPart: "", + wantedCfg: nil, + err: "url config: chunkDurSSR requires ssrAS to be configured", + }, + { + url: "/livesim2/ssras_1,2/chunkdurssr_1,0.2/asset.mpd", + nowMS: 0, + contentPart: "asset.mpd", + wantedCfg: &ResponseConfig{ + URLParts: []string{"", "livesim2", "ssras_1,2", "chunkdurssr_1,0.2", "asset.mpd"}, + URLContentIdx: 4, + StartTimeS: 0, + TimeShiftBufferDepthS: Ptr(60), + StartNr: Ptr(0), + AvailabilityTimeCompleteFlag: true, + TimeSubsDurMS: defaultTimeSubsDurMS, + SSRFlag: true, + SSRAS: "1,2", + ChunkDurSSR: "1,0.2", + }, + err: "", + }, } for _, c := range cases { diff --git a/cmd/livesim2/app/handler_livesim.go b/cmd/livesim2/app/handler_livesim.go index 798fb631..cf0fc118 100644 --- a/cmd/livesim2/app/handler_livesim.go +++ b/cmd/livesim2/app/handler_livesim.go @@ -15,6 +15,7 @@ import ( "net/url" "path" "path/filepath" + "regexp" "strconv" "strings" "text/template" @@ -315,11 +316,51 @@ func writeSegment(ctx context.Context, w http.ResponseWriter, log *slog.Logger, return code, nil } } - if cfg.AvailabilityTimeCompleteFlag { + if cfg.SSRFlag { + // Sub segment part (SSR/L3D) low-delay mode should return each subSegment as a separated response + newSegmentPart, subSegmentPart, err := calcSubSegmentPart(segmentPart) + // Check if there is a sub-segment part + if newSegmentPart == "" && subSegmentPart == "" { + return 0, writeLiveSegment(log, w, cfg, drmCfg, vodFS, a, segmentPart, nowMS, tt, isLast) + } + if err != nil { + return 0, err + } + + return 0, writeSubSegment(ctx, log, w, cfg, drmCfg, vodFS, a, newSegmentPart, subSegmentPart, nowMS, isLast) + } + if cfg.AvailabilityTimeCompleteFlag || isImage(segmentPart) { return 0, writeLiveSegment(log, w, cfg, drmCfg, vodFS, a, segmentPart, nowMS, tt, isLast) } - // Chunked low-latency mode - return 0, writeChunkedSegment(ctx, log, w, cfg, drmCfg, vodFS, a, segmentPart, nowMS, isLast) + // Only use chunked mode if chunk duration is explicitly configured + if cfg.ChunkDurS != nil { + return 0, writeChunkedSegment(ctx, log, w, cfg, drmCfg, vodFS, a, segmentPart, nowMS, isLast) + } + // Default to non-chunked + return 0, writeLiveSegment(log, w, cfg, drmCfg, vodFS, a, segmentPart, nowMS, tt, isLast) +} + +var subSegmentRegex = regexp.MustCompile(`^(.*)_(\d+)$`) + +func calcSubSegmentPart(segmentPart string) (string, string, error) { + ext := filepath.Ext(segmentPart) + + if ext == "" { + return "", "", fmt.Errorf("segment part has no extension: %s", segmentPart) + } + + segmentPartWithoutExtension := strings.TrimSuffix(segmentPart, ext) + matches := subSegmentRegex.FindStringSubmatch(segmentPartWithoutExtension) + + if len(matches) != 3 { + return "", "", nil + } + + originalSegment := matches[1] + subSegmentPart := matches[2] + newSegmentPart := originalSegment + ext + + return newSegmentPart, subSegmentPart, nil } // calcStatusCode returns the configured status code for the segment or 0 if none. @@ -372,7 +413,11 @@ func calcStatusCode(cfg *ResponseConfig, a *asset, segmentPart string, nowMS int func findLastSegNr(cfg *ResponseConfig, a *asset, nowMS int, rep *RepData) int { wTimes := calcWrapTimes(a, cfg, nowMS, mpd.Duration(60*time.Second)) - timeLineEntries := a.generateTimelineEntries(rep.ID, wTimes, 0) + timeLineEntries, err := a.generateTimelineEntries(rep.ID, wTimes, 0, nil) + if err != nil { + // This should not happen with nil chunk duration, but handle gracefully + return -1 + } return timeLineEntries.lastNr() } diff --git a/cmd/livesim2/app/handler_livesim_test.go b/cmd/livesim2/app/handler_livesim_test.go index 375c5a1f..aff82336 100644 --- a/cmd/livesim2/app/handler_livesim_test.go +++ b/cmd/livesim2/app/handler_livesim_test.go @@ -163,6 +163,13 @@ func TestFetches(t *testing.T) { wantedStatusCode: http.StatusOK, wantedContentType: `image/jpeg`, }, + { + desc: "thumbnail in low-latency mode", + url: "testpic_2s/thumbs/1.jpg?nowMS=12000", + params: "ato_1/chunkdur_1/", + wantedStatusCode: http.StatusOK, + wantedContentType: `image/jpeg`, + }, { desc: "imsc1 image subtitle", url: "testpic_2s/imsc1_img_en/300.m4s?nowMS=610000", @@ -248,3 +255,60 @@ func TestFetches(t *testing.T) { }) } } + +func TestCalcSubSegmentPart(t *testing.T) { + testCases := []struct { + desc string + segmentPart string + expNewSegmentPart string + expSubSegmentPart string + expectedErr bool + expectedContainsErr string + }{ + { + desc: "valid sub-segment", + segmentPart: "segment_1.m4s", + expNewSegmentPart: "segment.m4s", + expSubSegmentPart: "1", + expectedErr: false, + }, + { + desc: "no sub-segment part", + segmentPart: "segment.m4s", + expNewSegmentPart: "", + expSubSegmentPart: "", + expectedErr: false, + }, + { + desc: "missing sub-segment part", + segmentPart: "segment_.m4s", + expNewSegmentPart: "", + expSubSegmentPart: "", + expectedErr: false, + expectedContainsErr: "cannot parse sub-segment part", + }, + { + desc: "missing segment and sub-segment part", + segmentPart: "_.m4s", + expNewSegmentPart: "", + expSubSegmentPart: "", + expectedErr: false, + expectedContainsErr: "cannot parse original segment", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + newSegmentPart, subSegmentPart, err := calcSubSegmentPart(tc.segmentPart) + + if tc.expectedErr { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedContainsErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expNewSegmentPart, newSegmentPart) + require.Equal(t, tc.expSubSegmentPart, subSegmentPart) + } + }) + } +} diff --git a/cmd/livesim2/app/handler_urlgen.go b/cmd/livesim2/app/handler_urlgen.go index aab1cbfd..00c7abee 100644 --- a/cmd/livesim2/app/handler_urlgen.go +++ b/cmd/livesim2/app/handler_urlgen.go @@ -142,6 +142,8 @@ type urlGenData struct { Ato string // availabilityTimeOffset, floating point seconds or "inf" ChunkDur string // chunk duration (float in seconds) LlTarget int // low-latency target (in milliseconds) + SSRASConfig string // low delay Adaptation Set configuration (adaptationSetId,ssrValue;...) + ChunkDurSSR string // low delay chunk duration (float in seconds) TimeSubsStpp string // languages for generated subtitles in stpp-format (comma-separated) TimeSubsWvtt string // languages for generated subtitles in wvtt-format (comma-separated) TimeSubsDur string // cue duration of generated subtitles (in milliseconds) @@ -400,13 +402,37 @@ func createURL(r *http.Request, aInfo assetsInfo, drmCfg *drm.DrmConfig) urlGenD data.Traffic = traffic sb.WriteString(fmt.Sprintf("traffic_%s/", traffic)) } + ssrAS := q.Get("ssrAS") + if ssrAS != "" { + if err := validateSSRAS(ssrAS); err != nil { + data.Errors = append(data.Errors, fmt.Sprintf("invalid ssrAS: %s", err.Error())) + } else { + data.SSRASConfig = ssrAS + sb.WriteString(fmt.Sprintf("ssras_%s/", ssrAS)) + } + } + chunkDurSSR := q.Get("chunkDurSSR") + if chunkDurSSR != "" { + if err := validateChunkDurSSR(chunkDurSSR); err != nil { + data.Errors = append(data.Errors, fmt.Sprintf("invalid chunkDurSSR: %s", err.Error())) + } else { + data.ChunkDurSSR = chunkDurSSR + sb.WriteString(fmt.Sprintf("chunkdurssr_%s/", chunkDurSSR)) + } + } + sb.WriteString(fmt.Sprintf("%s/%s", asset, mpd)) + if annexI != "" { query, err := queryFromAnnexI(annexI) if err != nil { data.Errors = append(data.Errors, fmt.Sprintf("bad annexI: %s", err.Error())) } - sb.WriteString(query) + if strings.Contains(sb.String(), "?") { + sb.WriteString(strings.Replace(query, "?", "&", 1)) + } else { + sb.WriteString(query) + } } if len(data.Errors) > 0 { data.URL = "" @@ -419,6 +445,57 @@ func createURL(r *http.Request, aInfo assetsInfo, drmCfg *drm.DrmConfig) urlGenD return data } +// validateSSRAS validates the format adaptationSetId,ssrValue;adaptationSetId,ssrValue;... +// where both adaptationSetId and ssrValue must be integers +func validateSSRAS(config string) error { + if config == "" { + return nil + } + + pairs := strings.Split(config, ";") + for _, pair := range pairs { + parts := strings.Split(pair, ",") + if len(parts) != 2 { + return fmt.Errorf("invalid format in pair '%s': expected 'adaptationSetId,ssrValue'", pair) + } + + adaptationSetId := strings.TrimSpace(parts[0]) + if id, err := strconv.Atoi(adaptationSetId); err != nil || id < 0 { + return fmt.Errorf("adaptationSetId '%s' must be a non-negative integer", adaptationSetId) + } + + ssrValue := strings.TrimSpace(parts[1]) + if _, err := strconv.Atoi(ssrValue); err != nil { + return fmt.Errorf("ssrValue '%s' must be an integer", ssrValue) + } + } + return nil +} + +// validateChunkDurSSR validates the format adaptationSetId,chunkDuration;adaptationSetId,chunkDuration;... +// where adaptationSetId must be an integer and chunkDuration must be a decimal number in seconds (e.g., 1, 0.1) +func validateChunkDurSSR(config string) error { + if config == "" { + return nil + } + pairs := strings.Split(config, ";") + for _, pair := range pairs { + parts := strings.Split(pair, ",") + if len(parts) != 2 { + return fmt.Errorf("invalid format in pair '%s': expected 'adaptationSetId,chunkDuration'", pair) + } + adaptationSetId := strings.TrimSpace(parts[0]) + if id, err := strconv.Atoi(adaptationSetId); err != nil || id < 0 { + return fmt.Errorf("adaptationSetId '%s' must be a non-negative integer", adaptationSetId) + } + chunkDuration := strings.TrimSpace(parts[1]) + if _, err := strconv.ParseFloat(chunkDuration, 64); err != nil { + return fmt.Errorf("chunkDuration '%s' must be a decimal number in seconds", chunkDuration) + } + } + return nil +} + func queryFromAnnexI(annexI string) (string, error) { out := "" pairs := strings.Split(annexI, ",") diff --git a/cmd/livesim2/app/livempd.go b/cmd/livesim2/app/livempd.go index 6e5bdac2..668b38c8 100644 --- a/cmd/livesim2/app/livempd.go +++ b/cmd/livesim2/app/livempd.go @@ -10,6 +10,7 @@ import ( "log/slog" "math" "net/url" + "strconv" "strings" "time" @@ -18,6 +19,26 @@ import ( m "github.com/Eyevinn/dash-mpd/mpd" ) +const ( + ProfileAdvancedLinear = "urn:mpeg:dash:profile:advanced-linear:2025" +) + +func hasExtraSpaces(config string) bool { + if config != strings.TrimSpace(config) { + return true + } + return strings.Contains(config, " ;") || strings.Contains(config, "; ") || + strings.Contains(config, " ,") || strings.Contains(config, ", ") +} + +// addAdvancedLinearProfileIfMissing adds the AdvancedLinear profile to the profiles string if it's not already present +func addAdvancedLinearProfileIfMissing(profiles m.ListOfProfilesType) m.ListOfProfilesType { + if strings.Contains(string(profiles), ProfileAdvancedLinear) { + return profiles + } + return profiles.AddProfile(ProfileAdvancedLinear) +} + type wrapTimes struct { startWraps int startWrapMS int @@ -96,6 +117,20 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi } } + // Parse SSR configuration + ssrNextMap, ssrPrevMap, err := parseSSRAS(cfg.SSRAS) + if err != nil { + return nil, fmt.Errorf("parse SSRAS: %w", err) + } + chunkDurSSRMap, err := parseChunkDurSSR(cfg.ChunkDurSSR) + if err != nil { + return nil, fmt.Errorf("parse ChunkDurSSR: %w", err) + } + + if cfg.SSRFlag { + mpd.Profiles = addAdvancedLinearProfileIfMissing(mpd.Profiles) + } + if cfg.getAvailabilityTimeOffsetS() > 0 { if !cfg.AvailabilityTimeCompleteFlag { if cfg.LatencyTargetMS == nil { @@ -137,6 +172,9 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi if as.SegmentTemplate != nil { as.SegmentTemplate.EndNumber = nil // Never output endNumber } + + var explicitChunkDurS = (*float64)(nil) + switch as.ContentType { case "video", "audio": if cfg.PatchTTL > 0 && as.Id == nil { @@ -243,6 +281,51 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi Value: "", }) } + + if cfg.SSRFlag && as.Id != nil { + prevID, prevIDExists := ssrPrevMap[*as.Id] + nextID, nextIDExists := ssrNextMap[*as.Id] + + var prevIDPtr *uint32 + if prevIDExists { + prevIDPtr = &prevID + } + + if nextIDExists { + //Low Delay Adaptation Set + updateSSRAdaptationSet(as, nextID, prevIDPtr, chunkDurSSRMap, &explicitChunkDurS) + } else if prevIDExists { + // Regular Adaptation Set for switching + updateSwitchingAdaptationSet(as, prevID) + // Low Latency Adaptation Set + if cfg.ChunkDurS != nil { + explicitChunkDurS = cfg.ChunkDurS //K calculation + + if as.SegmentTemplate != nil { + as.SegmentTemplate.Media = strings.ReplaceAll(as.SegmentTemplate.Media, "$Number$", "$Number$_$SubNumber$") + } + + as.StartWithSAP = 1 + + if as.ContentType == "video" { + ep := m.NewDescriptor(SsrSchemeIdUri, "", "") + as.EssentialProperties = append(as.EssentialProperties, ep) + } + } + + } + } + + // Update RepData with ChunkDurSSR if configured + if explicitChunkDurS != nil { + // Update all representations of this adaptation set + for _, rep := range as.Representations { + if repData, exists := a.Reps[rep.Id]; exists { + repData.ChunkDurSSRS = explicitChunkDurS + } + } + } + atoMS, err := setOffsetInAdaptationSet(cfg, as) if err != nil { return nil, err @@ -250,14 +333,26 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi var se segEntries if asIdx == 0 { // Assume that first representation is as good as any, so can be reference - refSegEntries = a.generateTimelineEntries(as.Representations[0].Id, wTimes, atoMS) + var err error + refSegEntries, err = a.generateTimelineEntries(as.Representations[0].Id, wTimes, atoMS, explicitChunkDurS) + if err != nil { + return nil, err + } se = refSegEntries } else { switch as.ContentType { case "video", "text", "image": - se = a.generateTimelineEntries(as.Representations[0].Id, wTimes, atoMS) + var err error + se, err = a.generateTimelineEntries(as.Representations[0].Id, wTimes, atoMS, explicitChunkDurS) + if err != nil { + return nil, err + } case "audio": - se = a.generateTimelineEntriesFromRef(refSegEntries, as.Representations[0].Id) + var err error + se, err = a.generateTimelineEntriesFromRef(refSegEntries, as.Representations[0].Id, explicitChunkDurS) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unknown content type %s", as.ContentType) } @@ -285,7 +380,7 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi mpd.PublishTime = m.ConvertToDateTime(calcPublishTime(cfg, se.lsi)) } case segmentNumber: - err := adjustAdaptationSetForSegmentNumber(cfg, a, as) + err := adjustAdaptationSetForSegmentNumber(cfg, a, as, explicitChunkDurS) if err != nil { return nil, fmt.Errorf("adjustASForSegmentNumber: %w", err) } @@ -339,6 +434,49 @@ func LiveMPD(a *asset, mpdName string, cfg *ResponseConfig, drmCfg *drm.DrmConfi return mpd, nil } +func updateSSRAdaptationSet(as *m.AdaptationSetType, nextID uint32, prevID *uint32, + chunkDurSSRMap map[uint32]float64, explicitChunkDurS **float64) { + // Add SubNumber to SegmentTemplate + if as.SegmentTemplate != nil { + as.SegmentTemplate.Media = strings.ReplaceAll(as.SegmentTemplate.Media, "$Number$", "$Number$_$SubNumber$") + } + + // SupplementalProperty schemeIdUri="urn:mpeg:dash:adaptation-set-switching:2016" + var switchingValue string + if prevID != nil { + switchingValue = strconv.FormatUint(uint64(nextID), 10) + "," + strconv.FormatUint(uint64(*prevID), 10) + } else { + switchingValue = strconv.FormatUint(uint64(nextID), 10) + } + sp := m.NewDescriptor(AdaptationSetSwitchingSchemeIdUri, switchingValue, "") + as.SupplementalProperties = append(as.SupplementalProperties, sp) + + if chunkDur, exists := chunkDurSSRMap[*as.Id]; exists { + *explicitChunkDurS = &chunkDur + } + + if as.ContentType == "video" { + // Add SegmentSequenceProperties to signal Low-Delay + as.SegmentSequenceProperties = &m.SegmentSequencePropertiesType{ + SapType: 1, + Cadence: 1, + } + + // AdaptationSet@startWithSAP = 1 + as.StartWithSAP = 1 + + // EssentialProperty schemeIdUri="urn:mpeg:dash:ssr:2023" + ssrValue := strconv.FormatUint(uint64(nextID), 10) + ep := m.NewDescriptor(SsrSchemeIdUri, ssrValue, "") + as.EssentialProperties = append(as.EssentialProperties, ep) + } +} + +func updateSwitchingAdaptationSet(as *m.AdaptationSetType, prevID uint32) { + sp := m.NewDescriptor(AdaptationSetSwitchingSchemeIdUri, strconv.FormatUint(uint64(prevID), 10), "") + as.SupplementalProperties = append(as.SupplementalProperties, sp) +} + // lastPeriodStartTime returns the absolute startTime of the last Period. func lastPeriodStartTime(mpd *m.MPD) (m.DateTime, error) { lastPeriod := mpd.Periods[len(mpd.Periods)-1] @@ -613,7 +751,7 @@ func adjustAdaptationSetForTimelineNr(se segEntries, as *m.AdaptationSetType) er return nil } -func adjustAdaptationSetForSegmentNumber(cfg *ResponseConfig, a *asset, as *m.AdaptationSetType) error { +func adjustAdaptationSetForSegmentNumber(cfg *ResponseConfig, a *asset, as *m.AdaptationSetType, explicitChunkDurS *float64) error { if as.SegmentTemplate.Duration == nil { r0 := as.Representations[0] rep0 := a.Reps[r0.Id] @@ -634,6 +772,15 @@ func adjustAdaptationSetForSegmentNumber(cfg *ResponseConfig, a *asset, as *m.Ad as.SegmentTemplate.StartNumber = startNr } as.SegmentTemplate.Media = strings.ReplaceAll(as.SegmentTemplate.Media, "$Time$", "$Number$") + + if cfg.SSRFlag && explicitChunkDurS != nil { +k, err := calculateK(uint64(*as.SegmentTemplate.Duration), int(as.SegmentTemplate.GetTimescale()), explicitChunkDurS) + if err != nil { + return err + } + as.SegmentTemplate.K = k + } + return nil } @@ -913,3 +1060,83 @@ func contentTypeFromMimeType(mimeType string) string { return "" } } + +// parseSSRAS parses the ssrAS configuration once +// and returns maps for next and previous adaptation set relationships. +func parseSSRAS(config string) (nextMap, prevMap map[uint32]uint32, err error) { + if config == "" { + return nil, nil, nil + } + + nextMap = make(map[uint32]uint32) + prevMap = make(map[uint32]uint32) + + if hasExtraSpaces(config) { + return nil, nil, fmt.Errorf("configuration contains extra spaces: use exact format 'adaptationSetId,ssrValue;...' without spaces") + } + + pairs := strings.Split(config, ";") + for _, pair := range pairs { + parts := strings.Split(pair, ",") + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid format in pair '%s': expected 'adaptationSetId,ssrValue'", pair) + } + adaptationSetIDStr := strings.TrimSpace(parts[0]) + ssrValueStr := strings.TrimSpace(parts[1]) + + adaptationSetID, err := strconv.ParseUint(adaptationSetIDStr, 10, 32) + if err != nil { + return nil, nil, fmt.Errorf("invalid adaptationSetId '%s' in pair '%s': must be a valid unsigned integer", adaptationSetIDStr, pair) + } + + ssrValue, err := strconv.ParseUint(ssrValueStr, 10, 32) + if err != nil { + return nil, nil, fmt.Errorf("invalid ssrValue '%s' in pair '%s': must be a valid unsigned integer", ssrValueStr, pair) + } + + adaptationSetID32 := uint32(adaptationSetID) + ssrValue32 := uint32(ssrValue) + + nextMap[adaptationSetID32] = ssrValue32 + prevMap[ssrValue32] = adaptationSetID32 + } + + return +} + +// parseChunkDurSSR parses the ChunkDurSSR configuration +// and returns a map where the key is adaptationSetId and value is chunkDuration in seconds. +func parseChunkDurSSR(config string) (map[uint32]float64, error) { + if config == "" { + return nil, nil + } + + if hasExtraSpaces(config) { + return nil, fmt.Errorf("configuration contains extra spaces: use exact format 'adaptationSetId,chunkDuration;...' without spaces") + } + + chunkDurMap := make(map[uint32]float64) + pairs := strings.Split(config, ";") + for _, pair := range pairs { + parts := strings.Split(pair, ",") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid format in pair '%s': expected 'adaptationSetId,chunkDuration'", pair) + } + adaptationSetIDStr := strings.TrimSpace(parts[0]) + chunkDurationStr := strings.TrimSpace(parts[1]) + + adaptationSetID, err := strconv.ParseUint(adaptationSetIDStr, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid adaptationSetId '%s' in pair '%s': must be a valid unsigned integer", adaptationSetIDStr, pair) + } + + chunkDuration, err := strconv.ParseFloat(chunkDurationStr, 64) + if err != nil { + return nil, fmt.Errorf("invalid chunkDuration '%s' in pair '%s': must be a valid number", chunkDurationStr, pair) + } + + chunkDurMap[uint32(adaptationSetID)] = chunkDuration + } + + return chunkDurMap, nil +} diff --git a/cmd/livesim2/app/livempd_test.go b/cmd/livesim2/app/livempd_test.go index bb41f4e8..33c9f06d 100644 --- a/cmd/livesim2/app/livempd_test.go +++ b/cmd/livesim2/app/livempd_test.go @@ -359,7 +359,8 @@ func TestLastAvailableSegment(t *testing.T) { } else { require.NoError(t, err) r := as.Representations[0] // Assume that any representation will be fine inside AS - se := asset.generateTimelineEntries(r.Id, wTimes, atoMS) + se, err := asset.generateTimelineEntries(r.Id, wTimes, atoMS, nil) + require.NoError(t, err) assert.Equal(t, tc.wantedSegNr, se.lsi.nr) } } @@ -1041,6 +1042,507 @@ func TestEndNumberRemovedFromMPD(t *testing.T) { } } +func TestGenerateTimelineEntries(t *testing.T) { + vodFS := os.DirFS("testdata/assets") + + am := newAssetMgr(vodFS, "", false) + + logger := slog.Default() + + err := am.discoverAssets(logger) + require.NoError(t, err) + + asset, ok := am.findAsset("testpic_2s") + require.True(t, ok) + + cases := []struct { + desc string + reID string + wt wrapTimes + atoMS int + chunkDur *float64 + expectedStartNr int + expectedLsiNr int + expectedMediaTimescale uint32 + expectedEntries []*m.S + expectedError string + }{ + { + desc: "With chunkDuration of 0.5s expecting S@k=4", + reID: "V300", + wt: wrapTimes{startRelMS: 0, nowRelMS: 7000, startWraps: 0, nowWraps: 0}, + atoMS: 0, + chunkDur: Ptr(0.5), + expectedStartNr: 0, + expectedLsiNr: 2, + expectedMediaTimescale: 90000, + expectedEntries: []*m.S{ + {T: Ptr(uint64(0)), D: 180000, R: 2, CommonSegmentSequenceAttributes: m.CommonSegmentSequenceAttributes{K: Ptr(uint64(4))}}, + }, + }, + { + desc: "With chunkDuration of 2.1s expecting error (chunk duration >= segment duration)", + reID: "V300", + wt: wrapTimes{startRelMS: 0, nowRelMS: 7000, startWraps: 0, nowWraps: 0}, + atoMS: 0, + chunkDur: Ptr(2.1), + expectedError: "chunk duration 2.10s must be less than or equal to segment duration 2.00s", + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + se, err := asset.generateTimelineEntries(tc.reID, tc.wt, tc.atoMS, tc.chunkDur) + + if tc.expectedError != "" { + require.Error(t, err) + require.Equal(t, tc.expectedError, err.Error()) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedStartNr, se.startNr, "startNr mismatch") + assert.Equal(t, tc.expectedLsiNr, se.lsi.nr, "last segment info (nr) mismatch") + assert.Equal(t, tc.expectedMediaTimescale, se.mediaTimescale, "mediaTimescale mismatch") + require.Equal(t, tc.expectedEntries, se.entries, "timeline entries mismatch") + }) + } +} + +func TestParseSSRAS(t *testing.T) { + successCases := []struct { + desc string + config string + expectedNext map[uint32]uint32 + expectedPrev map[uint32]uint32 + }{ + { + desc: "empty config", + config: "", + expectedNext: nil, + expectedPrev: nil, + }, + { + desc: "single pair", + config: "1,2", + expectedNext: map[uint32]uint32{1: 2}, + expectedPrev: map[uint32]uint32{2: 1}, + }, + { + desc: "multiple pairs", + config: "1,2;3,4;5,6", + expectedNext: map[uint32]uint32{1: 2, 3: 4, 5: 6}, + expectedPrev: map[uint32]uint32{2: 1, 4: 3, 6: 5}, + }, + } + + for _, tc := range successCases { + t.Run(tc.desc, func(t *testing.T) { + nextMap, prevMap, err := parseSSRAS(tc.config) + assert.NoError(t, err) + assert.Equal(t, tc.expectedNext, nextMap, "nextMap mismatch") + assert.Equal(t, tc.expectedPrev, prevMap, "prevMap mismatch") + }) + } + + errorCases := []struct { + desc string + config string + }{ + { + desc: "extra spaces around semicolon", + config: "1,2 ; 3,4", + }, + { + desc: "extra spaces around comma", + config: "1 , 2;3,4", + }, + { + desc: "leading spaces", + config: " 1,2;3,4", + }, + { + desc: "trailing spaces", + config: "1,2;3,4 ", + }, + { + desc: "invalid format - single value", + config: "1", + }, + { + desc: "invalid format - three values", + config: "1,2,3", + }, + { + desc: "invalid format - empty pair", + config: "1,2;;3,4", + }, + { + desc: "invalid adaptation set ID", + config: "abc,2", + }, + { + desc: "invalid SSR value", + config: "1,def", + }, + { + desc: "both values invalid", + config: "abc,def", + }, + { + desc: "mixed valid and invalid pairs", + config: "1,2;invalid,pair;3,4", + }, + } + + for _, tc := range errorCases { + t.Run(tc.desc, func(t *testing.T) { + nextMap, prevMap, err := parseSSRAS(tc.config) + assert.Error(t, err) + assert.Nil(t, nextMap) + assert.Nil(t, prevMap) + }) + } +} + +func TestParseChunkDurSSR(t *testing.T) { + successCases := []struct { + desc string + config string + expected map[uint32]float64 + }{ + { + desc: "empty config", + config: "", + expected: nil, + }, + { + desc: "single pair with integer duration", + config: "1,2", + expected: map[uint32]float64{1: 2.0}, + }, + { + desc: "single pair with float duration", + config: "1,0.5", + expected: map[uint32]float64{1: 0.5}, + }, + { + desc: "multiple pairs with mixed durations", + config: "1,1.0;2,0.1;3,2.5", + expected: map[uint32]float64{1: 1.0, 2: 0.1, 3: 2.5}, + }, + } + + for _, tc := range successCases { + t.Run(tc.desc, func(t *testing.T) { + result, err := parseChunkDurSSR(tc.config) + assert.NoError(t, err) + + // Handle nil maps more robustly + if tc.expected == nil { + assert.Nil(t, result, "result should be nil for empty config") + } else { + assert.Equal(t, tc.expected, result, "chunk duration map mismatch") + } + }) + } + + errorCases := []struct { + desc string + config string + }{ + { + desc: "extra spaces around semicolon", + config: "1,1.0 ; 2,2.0", + }, + { + desc: "extra spaces around comma", + config: "1 , 1.0;2,2.0", + }, + { + desc: "leading spaces", + config: " 1,1.0;2,2.0", + }, + { + desc: "trailing spaces", + config: "1,1.0;2,2.0 ", + }, + { + desc: "invalid format - single value", + config: "1", + }, + { + desc: "invalid format - three values", + config: "1,2.0,3", + }, + { + desc: "invalid format - empty pair", + config: "1,1.0;;2,2.0", + }, + { + desc: "invalid adaptation set ID", + config: "abc,1.5", + }, + { + desc: "invalid chunk duration", + config: "1,abc", + }, + { + desc: "both values invalid", + config: "abc,def", + }, + { + desc: "mixed valid and invalid pairs", + config: "1,1.0;invalid,pair;3,0.5", + }, + } + + for _, tc := range errorCases { + t.Run(tc.desc, func(t *testing.T) { + result, err := parseChunkDurSSR(tc.config) + assert.Error(t, err) + assert.Nil(t, result) + }) + } +} + +func TestParseSSRAS_ErrorCases(t *testing.T) { + cases := []struct { + desc string + config string + wantErr string + }{ + { + desc: "invalid pair format - only one number", + config: "1", + wantErr: "invalid format in pair '1': expected 'adaptationSetId,ssrValue'", + }, + { + desc: "invalid pair format - too many numbers", + config: "1,2,3", + wantErr: "invalid format in pair '1,2,3': expected 'adaptationSetId,ssrValue'", + }, + { + desc: "configuration with extra spaces", + config: " 10 , 20 ; 30 , 40 ", + wantErr: "configuration contains extra spaces: use exact format 'adaptationSetId,ssrValue;...' without spaces", + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + _, _, err := parseSSRAS(tc.config) + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestParseChunkDurSSR_ErrorCases(t *testing.T) { + cases := []struct { + desc string + config string + wantErr string + }{ + { + desc: "invalid pair format - only one number", + config: "1", + wantErr: "invalid format in pair '1': expected 'adaptationSetId,chunkDuration'", + }, + { + desc: "invalid pair format - too many numbers", + config: "1,2,3", + wantErr: "invalid format in pair '1,2,3': expected 'adaptationSetId,chunkDuration'", + }, + { + desc: "configuration with extra spaces", + config: " 10 , 1.5 ; 20 , 0.25 ", + wantErr: "configuration contains extra spaces: use exact format 'adaptationSetId,chunkDuration;...' without spaces", + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + _, err := parseChunkDurSSR(tc.config) + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestUpdateSSRAdaptationSet(t *testing.T) { + cases := []struct { + desc string + as *m.AdaptationSetType + nextMap map[uint32]uint32 + prevMap map[uint32]uint32 + expectEssentialProperty bool + expectedSSRValue string + expectSupplementalProperty bool + expectedSwitchingValue string + expectSegmentSequenceProps bool + expectStartWithSAP bool + }{ + { + desc: "video adaptation set with SSR configuration", + as: &m.AdaptationSetType{ + Id: Ptr(uint32(2)), + ContentType: "video", + }, + nextMap: map[uint32]uint32{1: 2, 2: 3}, + prevMap: map[uint32]uint32{2: 1, 3: 2}, + expectEssentialProperty: true, + expectedSSRValue: "3", + expectSupplementalProperty: true, + expectedSwitchingValue: "3,1", + expectSegmentSequenceProps: true, + expectStartWithSAP: true, + }, + { + desc: "video adaptation set not in nextMap", + as: &m.AdaptationSetType{ + Id: Ptr(uint32(3)), + ContentType: "video", + }, + nextMap: map[uint32]uint32{1: 2}, + prevMap: map[uint32]uint32{2: 1}, + expectEssentialProperty: false, + expectSupplementalProperty: false, + expectSegmentSequenceProps: false, + expectStartWithSAP: false, + }, + { + desc: "audio adaptation set (should not be processed)", + as: &m.AdaptationSetType{ + Id: Ptr(uint32(1)), + ContentType: "audio", + }, + nextMap: map[uint32]uint32{1: 2}, + prevMap: map[uint32]uint32{2: 1}, + expectEssentialProperty: false, + expectSupplementalProperty: false, + expectSegmentSequenceProps: false, + expectStartWithSAP: false, + }, + { + desc: "adaptation set with nil ID", + as: &m.AdaptationSetType{ + ContentType: "video", + }, + nextMap: map[uint32]uint32{1: 2}, + prevMap: map[uint32]uint32{2: 1}, + expectEssentialProperty: false, + expectSupplementalProperty: false, + expectSegmentSequenceProps: false, + expectStartWithSAP: false, + }, + { + desc: "video adaptation set with switching value but no prev", + as: &m.AdaptationSetType{ + Id: Ptr(uint32(1)), + ContentType: "video", + }, + nextMap: map[uint32]uint32{1: 2}, + prevMap: map[uint32]uint32{3: 4}, + expectEssentialProperty: true, + expectedSSRValue: "2", + expectSupplementalProperty: true, + expectedSwitchingValue: "2", + expectSegmentSequenceProps: true, + expectStartWithSAP: true, + }, + { + desc: "video adaptation set with existing properties", + as: func() *m.AdaptationSetType { + as := &m.AdaptationSetType{ + Id: Ptr(uint32(2)), + ContentType: "video", + } + as.EssentialProperties = append(as.EssentialProperties, &m.DescriptorType{ + SchemeIdUri: "existing-scheme", + Value: "existing-value", + }) + as.SupplementalProperties = append(as.SupplementalProperties, &m.DescriptorType{ + SchemeIdUri: "existing-supplemental", + Value: "existing-value", + }) + return as + }(), + nextMap: map[uint32]uint32{1: 2, 2: 3}, + prevMap: map[uint32]uint32{2: 1, 3: 2}, + expectEssentialProperty: true, + expectedSSRValue: "3", + expectSupplementalProperty: true, + expectedSwitchingValue: "3,1", + expectSegmentSequenceProps: true, + expectStartWithSAP: true, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + originalEPCount := len(tc.as.EssentialProperties) + originalSPCount := len(tc.as.SupplementalProperties) + + var explicitChunkDurS *float64 + chunkDurSSRMap := make(map[uint32]float64) + + if tc.as.Id != nil && tc.as.ContentType == "video" { + nextID, nextExists := tc.nextMap[*tc.as.Id] + if nextExists { + var prevIDPtr *uint32 + if prevID, prevExists := tc.prevMap[*tc.as.Id]; prevExists { + prevIDPtr = &prevID + } + updateSSRAdaptationSet(tc.as, nextID, prevIDPtr, chunkDurSSRMap, &explicitChunkDurS) + } + } + + if tc.expectEssentialProperty { + assert.Greater(t, len(tc.as.EssentialProperties), originalEPCount, "EssentialProperty should be added") + found := false + for _, ep := range tc.as.EssentialProperties { + if ep.SchemeIdUri == SsrSchemeIdUri && ep.Value == tc.expectedSSRValue { + found = true + break + } + } + assert.True(t, found, "SSR EssentialProperty with correct value should be present") + } else { + assert.Equal(t, originalEPCount, len(tc.as.EssentialProperties), "No EssentialProperty should be added") + } + + if tc.expectSupplementalProperty { + assert.Greater(t, len(tc.as.SupplementalProperties), originalSPCount, "SupplementalProperty should be added") + found := false + for _, sp := range tc.as.SupplementalProperties { + if sp.SchemeIdUri == AdaptationSetSwitchingSchemeIdUri && sp.Value == tc.expectedSwitchingValue { + found = true + break + } + } + assert.True(t, found, "AdaptationSetSwitching SupplementalProperty with correct value should be present") + } else { + assert.Equal(t, originalSPCount, len(tc.as.SupplementalProperties), "No SupplementalProperty should be added") + } + + if tc.expectSegmentSequenceProps { + assert.NotNil(t, tc.as.SegmentSequenceProperties, "SegmentSequenceProperties should be set") + assert.Equal(t, uint32(1), tc.as.SegmentSequenceProperties.SapType) + assert.Equal(t, uint32(1), tc.as.SegmentSequenceProperties.Cadence) + } else { + assert.Nil(t, tc.as.SegmentSequenceProperties, "SegmentSequenceProperties should not be set") + } + + if tc.expectStartWithSAP { + assert.Equal(t, uint32(1), tc.as.StartWithSAP) + } else { + assert.Equal(t, uint32(0), tc.as.StartWithSAP) + } + }) + } +} + // TestEditListOffsetMPD tests that editListOffset affects MPD SegmentTimeline $Time$ values func TestEditListOffsetMPD(t *testing.T) { vodFS := os.DirFS("testdata/assets") @@ -1086,10 +1588,12 @@ func TestEditListOffsetMPD(t *testing.T) { // Generate timeline entries for reference (video) videoAS := mpd.Periods[0].AdaptationSets[0] // First should be video - refSE := asset.generateTimelineEntries(videoAS.Representations[0].Id, wTimes, atoMS) + refSE, err := asset.generateTimelineEntries(videoAS.Representations[0].Id, wTimes, atoMS, nil) + require.NoError(t, err) // Generate timeline entries for audio using reference - audioSE := asset.generateTimelineEntriesFromRef(refSE, "aac") + audioSE, err := asset.generateTimelineEntriesFromRef(refSE, "aac", nil) + require.NoError(t, err) require.Greater(t, len(audioSE.entries), 0, "Should have audio segments") firstSegTime := *audioSE.entries[0].T @@ -1120,10 +1624,12 @@ func TestEditListOffsetMPD(t *testing.T) { // Generate timeline entries for reference (video) videoAS := mpd.Periods[0].AdaptationSets[0] // First should be video - refSE := asset.generateTimelineEntries(videoAS.Representations[0].Id, wTimes, atoMS) + refSE, err := asset.generateTimelineEntries(videoAS.Representations[0].Id, wTimes, atoMS, nil) + require.NoError(t, err) // Generate timeline entries for audio using reference - audioSE := asset.generateTimelineEntriesFromRef(refSE, "aac") + audioSE, err := asset.generateTimelineEntriesFromRef(refSE, "aac", nil) + require.NoError(t, err) require.Greater(t, len(audioSE.entries), 0, "Should have audio segments") firstSegTime := *audioSE.entries[0].T diff --git a/cmd/livesim2/app/livesegment.go b/cmd/livesim2/app/livesegment.go index 61490fc8..a0e410a1 100644 --- a/cmd/livesim2/app/livesegment.go +++ b/cmd/livesim2/app/livesegment.go @@ -661,40 +661,39 @@ func findRefSegMeta(a *asset, cfg *ResponseConfig, segmentPart string, nowMS int return refMeta, nil } -// writeChunkedSegment splits a segment into chunks and send them as they become available timewise. -// -// nowMS servers as reference for the current time and can be set to any value. Media time will -// be incremented with respect to nowMS. -func writeChunkedSegment(ctx context.Context, log *slog.Logger, w http.ResponseWriter, cfg *ResponseConfig, drmCfg *drm.DrmConfig, - vodFS fs.FS, a *asset, segmentPart string, nowMS int, isLast bool) error { - - log.Debug("writeChunkedSegment", "segmentPart", segmentPart) +// prepareChunks generates a live segment, chunks it, and encrypts it if needed. +func prepareChunks(log *slog.Logger, vodFS fs.FS, a *asset, cfg *ResponseConfig, drmCfg *drm.DrmConfig, + segmentPart string, nowMS int, isLast bool, chunkIndex *int) (segOut, []chunk, error) { so, err := genLiveSegment(log, vodFS, a, cfg, segmentPart, nowMS, isLast) if err != nil { - return fmt.Errorf("convertToLive: %w", err) + return so, nil, fmt.Errorf("convertToLive: %w", err) + } + if isImage(segmentPart) { + return so, nil, nil } if so.seg == nil { - return fmt.Errorf("no segment data for chunked segment") + return so, nil, fmt.Errorf("no segment data for chunked segment") } - w.Header().Set("Content-Type", so.meta.rep.SegmentType()) - if isImage(segmentPart) { - w.Header().Set("Content-Length", strconv.Itoa(len(so.data))) - _, err = w.Write(so.data) - return fmt.Errorf("could not write image segment: %w", err) - } rep := so.meta.rep seg := so.seg - // Some part of the segment should be available, and is delivered directly. - // The rest are returned HTTP chunks as time passes. - // In general, we should extract all the samples and build a new one with the right fragment duration. - // That fragment/chunk duration is segment_duration-availabilityTimeOffset. - chunkDur := (a.SegmentDurMS - int(cfg.AvailabilityTimeOffsetS*1000)) * int(rep.MediaTimescale) / 1000 - chunks, err := chunkSegment(rep.initSeg, seg, so.meta, chunkDur) + // Calculate chunk duration in media timescale units + var chunkDur int + if rep.ChunkDurSSRS != nil && *rep.ChunkDurSSRS > 0 { + // Use low delay chunk duration from adaptation set configuration + chunkDur = int(*rep.ChunkDurSSRS * float64(rep.MediaTimescale)) + } else if cfg.ChunkDurS != nil && *cfg.ChunkDurS > 0 { + // Use explicit chunk duration from URL parameter + chunkDur = int(*cfg.ChunkDurS * float64(rep.MediaTimescale)) + } else { + // No chunk duration configured - chunking should not be performed + return so, nil, fmt.Errorf("chunking requested but no chunk duration configured (chunkDurS or chunkDurSSR)") + } + chunks, err := chunkSegment(rep.initSeg, seg, so.meta, chunkDur, chunkIndex) if err != nil { - return fmt.Errorf("chunkSegment: %w", err) + return so, nil, fmt.Errorf("chunkSegment: %w", err) } if cfg.DRM != "" { frags := make([]*mp4.Fragment, len(chunks)) @@ -703,9 +702,47 @@ func writeChunkedSegment(ctx context.Context, log *slog.Logger, w http.ResponseW } err := encryptFrags(log, cfg, drmCfg, rep, frags) if err != nil { - return fmt.Errorf("encryptFrags: %w", err) + return so, nil, fmt.Errorf("encryptFrags: %w", err) } } + return so, chunks, nil +} + +func setHeaders(w http.ResponseWriter, so segOut, segmentPart string) error { + w.Header().Set("Content-Type", so.meta.rep.SegmentType()) + if isImage(segmentPart) { + w.Header().Set("Content-Length", strconv.Itoa(len(so.data))) + _, err := w.Write(so.data) + if err != nil { + return fmt.Errorf("could not write image segment: %w", err) + } + return nil + } + return nil +} + +// writeChunkedSegment splits a segment into chunks and send them as they become available timewise. +// +// nowMS serves as reference for the current time and can be set to any value. Media time will +// be incremented with respect to nowMS. +func writeChunkedSegment(ctx context.Context, log *slog.Logger, w http.ResponseWriter, cfg *ResponseConfig, drmCfg *drm.DrmConfig, + vodFS fs.FS, a *asset, segmentPart string, nowMS int, isLast bool) error { + + log.Debug("writeChunkedSegment", "segmentPart", segmentPart) + + so, chunks, err := prepareChunks(log, vodFS, a, cfg, drmCfg, segmentPart, nowMS, isLast, nil) + if err != nil { + return err + } + + err = setHeaders(w, so, segmentPart) + if err != nil { + return err + } + if isImage(segmentPart) { + return nil + } + rep := so.meta.rep startUnixMS := unixMS() chunkAvailTime := int(so.meta.newTime) + cfg.StartTimeS*int(rep.MediaTimescale) @@ -740,6 +777,58 @@ func writeChunkedSegment(ctx context.Context, log *slog.Logger, w http.ResponseW return nil } +func writeSubSegment(ctx context.Context, log *slog.Logger, w http.ResponseWriter, cfg *ResponseConfig, drmCfg *drm.DrmConfig, + vodFS fs.FS, a *asset, segmentPart string, subSegmentPart string, nowMS int, isLast bool) error { + + log.Debug("writeSubSegment", "segmentPart", segmentPart, "subSegmentPart", subSegmentPart) + + chunkIndex, err := strconv.Atoi(subSegmentPart) + if err != nil { + return fmt.Errorf("bad chunk index: %w", err) + } + if chunkIndex < 0 { + return fmt.Errorf("non-positive chunk index: %d", chunkIndex) + } + + so, chunk, err := prepareChunks(log, vodFS, a, cfg, drmCfg, segmentPart, nowMS, isLast, &chunkIndex) + if err != nil { + return err + } + + err = setHeaders(w, so, segmentPart) + if err != nil { + return err + } + if isImage(segmentPart) { + return nil + } + rep := so.meta.rep + + chunkAvailTime := int(so.meta.newTime) + cfg.StartTimeS*int(rep.MediaTimescale) + + if len(chunk) != 1 { + return fmt.Errorf("get chunk %d: expected 1 chunk, got %d", chunkIndex, len(chunk)) + } + + chk := chunk[0] + + chunkAvailTime += int(chk.dur) + + if ctx.Err() != nil { + return ctx.Err() + } + + chunkAvailMS := chunkAvailTime * 1000 / int(rep.MediaTimescale) + if chunkAvailMS < nowMS { + err = writeChunk(w, chk) + if err != nil { + return fmt.Errorf("writeChunk: %w", err) + } + return nil + } + return nil +} + func unixMS() int { return int(time.Now().UnixMilli()) } @@ -761,7 +850,10 @@ func createChunk(styp *mp4.StypBox, trackID, seqNr uint32) chunk { // chunkSegment splits a segment into chunks of specified duration. // The first chunk gets an styp box if one is available in the incoming segment. -func chunkSegment(init *mp4.InitSegment, seg *mp4.MediaSegment, segMeta segMeta, chunkDur int) ([]chunk, error) { +// If chunkIndex is non-nil, returns only the chunk at that specific index (0-based). +// Note: This processes all chunks up to the requested index, which is inefficient +// for large segments when only the last chunk is needed. +func chunkSegment(init *mp4.InitSegment, seg *mp4.MediaSegment, segMeta segMeta, chunkDur int, chunkIndex *int) ([]chunk, error) { trex := init.Moov.Mvex.Trex fs := make([]mp4.FullSample, 0, 32) for _, f := range seg.Fragments { @@ -775,7 +867,6 @@ func chunkSegment(init *mp4.InitSegment, seg *mp4.MediaSegment, segMeta segMeta, trackID := init.Moov.Trak.Tkhd.TrackID ch := createChunk(seg.Styp, trackID, segMeta.newNr) chunkNr := 1 - var accChunkDur uint32 = 0 var totalDur = 0 sampleDecodeTime := segMeta.newTime var thisChunkDur uint32 = 0 @@ -784,20 +875,32 @@ func chunkSegment(init *mp4.InitSegment, seg *mp4.MediaSegment, segMeta segMeta, ch.frag.AddFullSample(fs[i]) dur := fs[i].Dur sampleDecodeTime += uint64(dur) - accChunkDur += dur thisChunkDur += dur totalDur += int(dur) if totalDur >= chunkDur*chunkNr { ch.dur = uint64(thisChunkDur) - chunks = append(chunks, ch) + if chunkIndex == nil { + chunks = append(chunks, ch) + } else if (chunkNr - 1) == *chunkIndex { + chunks = append(chunks, ch) + return chunks, nil + } ch = createChunk(nil, trackID, segMeta.newNr) thisChunkDur = 0 chunkNr++ } } if thisChunkDur > 0 { - ch.dur = uint64(chunkDur) - chunks = append(chunks, ch) + ch.dur = uint64(thisChunkDur) + if chunkIndex == nil { + chunks = append(chunks, ch) + } else if (chunkNr - 1) == *chunkIndex { + chunks = append(chunks, ch) + } + } + + if chunkIndex != nil && len(chunks) == 0 { + return nil, fmt.Errorf("chunk %d not found", *chunkIndex) } return chunks, nil diff --git a/cmd/livesim2/app/livesegment_test.go b/cmd/livesim2/app/livesegment_test.go index d604d007..73efb49e 100644 --- a/cmd/livesim2/app/livesegment_test.go +++ b/cmd/livesim2/app/livesegment_test.go @@ -255,6 +255,8 @@ func TestWriteChunkedSegment(t *testing.T) { cfg := NewResponseConfig() cfg.AvailabilityTimeCompleteFlag = false cfg.AvailabilityTimeOffsetS = 7.0 + chunkDur := 1.0 + cfg.ChunkDurS = &chunkDur err = logging.InitSlog("debug", "discard") require.NoError(t, err) @@ -917,3 +919,213 @@ func TestMehdBoxRemovedFromInitSegment(t *testing.T) { require.NotNil(t, initSeg) require.Nil(t, initSeg.Moov.Mvex.Mehd) } + +func TestWriteSubSegment(t *testing.T) { + vodFS := os.DirFS("testdata/assets") + am := newAssetMgr(vodFS, "", false) + err := logging.InitSlog("debug", "discard") + require.NoError(t, err) + log := slog.Default() + err = am.discoverAssets(log) + require.NoError(t, err) + + cases := []struct { + desc string + asset string + media string + subSegmentPart string + nowMS int + expSeqNr uint32 + expErr string + shouldPanic bool + }{ + { + desc: "first video sub-segment (8s)", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "0", + nowMS: 88_000, + expSeqNr: 10, + }, + { + desc: "last video sub-segment (8s)", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "7", + nowMS: 88_000, + expSeqNr: 10, + }, + { + desc: "valid sub-segment (8s segment)", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "1", + nowMS: 88_000, + expSeqNr: 10, + }, + { + desc: "too early", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "1", + nowMS: 79_000, + expErr: "createOutSeg: too early by", + }, + { + desc: "gone", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "1", + nowMS: 400_000, + expErr: "createOutSeg: gone", + }, + { + desc: "invalid sub-segment part (not a number)", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "abc", + nowMS: 86_000, + expErr: "bad chunk index: strconv.Atoi: parsing \"abc\": invalid syntax", + }, + { + desc: "invalid sub-segment index (out of bounds)", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "9", + nowMS: 88_000, + expErr: "chunk 9 not found", + }, + } + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + asset, ok := am.findAsset(tc.asset) + require.True(t, ok) + + cfg := NewResponseConfig() + cfg.SSRAS = "1,2" + cfg.ChunkDurSSR = "1,1.0" + cfg.ChunkDurS = Ptr(1.0) + + rr := httptest.NewRecorder() + ctx := context.Background() + + if tc.shouldPanic { + require.Panics(t, func() { + // This call is expected to panic + _ = writeSubSegment(ctx, log, rr, cfg, nil, vodFS, asset, tc.media, tc.subSegmentPart, tc.nowMS, false /* isLast */) + }) + + return + } + + err := writeSubSegment(ctx, log, rr, cfg, nil, vodFS, asset, tc.media, tc.subSegmentPart, tc.nowMS, false /* isLast */) + + if tc.expErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expErr) + return + } + require.NoError(t, err) + + seg := rr.Body.Bytes() + + // A subsegment is a (styp+)moof+mdat which is a valid file + sr := bits.NewFixedSliceReader(seg) + mp4d, err := mp4.DecodeFileSR(sr) + require.NoError(t, err) + require.Equal(t, 1, len(mp4d.Segments[0].Fragments)) + + moof := mp4d.Segments[0].Fragments[0].Moof + require.Equal(t, tc.expSeqNr, moof.Mfhd.SequenceNumber) + }) + } +} + +func TestWriteSubSegmentWithChunkDuration(t *testing.T) { + vodFS := os.DirFS("testdata/assets") + am := newAssetMgr(vodFS, "", false) + err := logging.InitSlog("debug", "discard") + require.NoError(t, err) + log := slog.Default() + err = am.discoverAssets(log) + require.NoError(t, err) + + cases := []struct { + desc string + asset string + media string + subSegmentPart string + nowMS int + chunkDurS *float64 + availabilityTimeOffsetS float64 + expSeqNr uint32 + expErr string + }{ + { + desc: "chunk 5 with explicit chunkDurS=0.2s and minimal availabilityTimeOffset", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "5", + nowMS: 88_000, + chunkDurS: Ptr(0.2), + availabilityTimeOffsetS: 0.1, + expSeqNr: 10, + }, + { + desc: "chunk 10 with explicit chunkDurS=0.2s and minimal availabilityTimeOffset", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "10", + nowMS: 88_000, + chunkDurS: Ptr(0.2), + availabilityTimeOffsetS: 0.1, + expSeqNr: 10, + }, + { + desc: "chunk 5 with explicit chunkDurS=0.2s and availabilityTimeOffset=1.8s", + asset: "testpic_8s", + media: "V300/10.m4s", + subSegmentPart: "5", + nowMS: 88_000, + chunkDurS: Ptr(0.2), + availabilityTimeOffsetS: 1.8, + expSeqNr: 10, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + asset, ok := am.findAsset(tc.asset) + require.True(t, ok) + + cfg := NewResponseConfig() + cfg.AvailabilityTimeCompleteFlag = false + cfg.SSRAS = "1,2" + cfg.AvailabilityTimeOffsetS = tc.availabilityTimeOffsetS + cfg.ChunkDurS = tc.chunkDurS + + rr := httptest.NewRecorder() + ctx := context.Background() + + err := writeSubSegment(ctx, slog.Default(), rr, cfg, nil, vodFS, asset, tc.media, tc.subSegmentPart, tc.nowMS, false) + + if tc.expErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expErr) + return + } + require.NoError(t, err) + + seg := rr.Body.Bytes() + + // A subsegment is a (styp+)moof+mdat which is a valid file + sr := bits.NewFixedSliceReader(seg) + mp4d, err := mp4.DecodeFileSR(sr) + require.NoError(t, err) + require.Equal(t, 1, len(mp4d.Segments[0].Fragments)) + + moof := mp4d.Segments[0].Fragments[0].Moof + require.Equal(t, tc.expSeqNr, moof.Mfhd.SequenceNumber) + }) + } +} diff --git a/cmd/livesim2/app/templates/urlgen.html b/cmd/livesim2/app/templates/urlgen.html index 8278cce0..f798a6fa 100644 --- a/cmd/livesim2/app/templates/urlgen.html +++ b/cmd/livesim2/app/templates/urlgen.html @@ -127,6 +127,19 @@

Livesim2 URL generator

+ +
+ SSR + + + +
MPD Patch... diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/1.m4s new file mode 100644 index 00000000..663b78c6 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/2.m4s new file mode 100644 index 00000000..44e171c3 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/3.m4s new file mode 100644 index 00000000..29137dcb Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/4.m4s new file mode 100644 index 00000000..af994e72 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/init.mp4 new file mode 100644 index 00000000..0b76d745 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/1080/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/1.m4s new file mode 100644 index 00000000..8415c990 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/2.m4s new file mode 100644 index 00000000..94010774 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/3.m4s new file mode 100644 index 00000000..a3b1cc09 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/4.m4s new file mode 100644 index 00000000..e8e1c9cd Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/init.mp4 new file mode 100644 index 00000000..58e8ce09 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/360/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/1.m4s new file mode 100644 index 00000000..03e4f1d6 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/2.m4s new file mode 100644 index 00000000..38e6a691 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/3.m4s new file mode 100644 index 00000000..4eab3556 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/4.m4s new file mode 100644 index 00000000..24763736 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/init.mp4 new file mode 100644 index 00000000..fd306cf4 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/720/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/1.m4s new file mode 100644 index 00000000..dea547d2 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/2.m4s new file mode 100644 index 00000000..f6837459 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/3.m4s new file mode 100644 index 00000000..c8df056d Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/4.m4s new file mode 100644 index 00000000..bc3063e0 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/init.mp4 new file mode 100644 index 00000000..e332e81d Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/A48/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/1.m4s new file mode 100644 index 00000000..ea3f6f73 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/2.m4s new file mode 100644 index 00000000..036362e7 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/3.m4s new file mode 100644 index 00000000..4086e17f Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/4.m4s new file mode 100644 index 00000000..54686a4b Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/init.mp4 new file mode 100644 index 00000000..25a6fd8b Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_1080/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/1.m4s new file mode 100644 index 00000000..44568eed Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/2.m4s new file mode 100644 index 00000000..27652a04 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/3.m4s new file mode 100644 index 00000000..30e5a4f2 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/4.m4s new file mode 100644 index 00000000..c57eabce Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/init.mp4 new file mode 100644 index 00000000..17b90688 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_360/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/1.m4s new file mode 100644 index 00000000..9e332b5a Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/2.m4s new file mode 100644 index 00000000..84adf2d6 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/3.m4s new file mode 100644 index 00000000..e182bdc6 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/4.m4s new file mode 100644 index 00000000..0eaf7543 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/init.mp4 new file mode 100644 index 00000000..2510e986 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_720/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/1.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/1.m4s new file mode 100644 index 00000000..dea547d2 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/1.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/2.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/2.m4s new file mode 100644 index 00000000..f6837459 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/2.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/3.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/3.m4s new file mode 100644 index 00000000..c8df056d Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/3.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/4.m4s b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/4.m4s new file mode 100644 index 00000000..bc3063e0 Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/4.m4s differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/init.mp4 b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/init.mp4 new file mode 100644 index 00000000..e332e81d Binary files /dev/null and b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/LD_A48/init.mp4 differ diff --git a/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/Manifest.mpd b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/Manifest.mpd new file mode 100644 index 00000000..997d04e6 --- /dev/null +++ b/cmd/livesim2/app/testdata/assets/testpic_2s_low_delay/Manifest.mpd @@ -0,0 +1,42 @@ + + + + + Manifest.mpd generated by GPAC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +