Repository: Eyevinn/mp4ff
Pull request #282 (2023-10-08 15:21:31)

feat: CommonSampleDuration methods for fragmented files.
New methods CommonSampleDuration for both MediaSegment, Fragment, and TrunBox.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3eb189a..b5b1ed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - MediaSegment and Fragment have new StartPos attribute
 - mp4.File now has Mfra pointer
 - MfraBox has new method FindEntry
+- MediaSegment, Fragment, and Trun method CommonSampleDuration
 
 ### Fixed
 
diff --git a/mp4/fragment.go b/mp4/fragment.go
index 8361025..17656d7 100644
--- a/mp4/fragment.go
+++ b/mp4/fragment.go
@@ -422,6 +422,9 @@ func (f *Fragment) AddSampleInterval(sItvl SampleInterval) error {
 	moof := f.Moof
 	traf := moof.Traf
 	trun := traf.Trun
+	if len(moof.Trafs) != 1 || len(traf.Truns) != 1 {
+		return fmt.Errorf("not exactly one track and one trun in fragment")
+	}
 	if trun.SampleCount() == 0 {
 		traf.Tfdt.SetBaseMediaDecodeTime(sItvl.FirstDecodeTime)
 	}
@@ -429,3 +432,43 @@ func (f *Fragment) AddSampleInterval(sItvl SampleInterval) error {
 	f.Mdat.AddSampleDataPart(sItvl.Data)
 	return nil
 }
+
+// CommonSampleDuration returns a common non-zero sample duration for a track defined by trex if available.
+func (f *Fragment) CommonSampleDuration(trex *TrexBox) (uint32, error) {
+	if trex == nil {
+		return 0, fmt.Errorf("trex not set")
+	}
+	moof := f.Moof
+	var traf *TrafBox
+	trackID := trex.TrackID
+	for _, t := range moof.Trafs {
+		if t.Tfhd.TrackID == trackID {
+			traf = t
+			break
+		}
+	}
+	if traf == nil {
+		return 0, fmt.Errorf("no track with trex trackID=%d", trackID)
+	}
+	commonDur := trex.DefaultSampleDuration
+	if traf.Tfhd.HasDefaultSampleDuration() {
+		commonDur = traf.Tfhd.DefaultSampleDuration
+	}
+	for _, trun := range traf.Truns {
+		cDur := trun.CommonSampleDuration(commonDur)
+		if commonDur != 0 {
+			if cDur != commonDur {
+				commonDur = 0
+			}
+		} else {
+			commonDur = cDur
+		}
+		if commonDur == 0 {
+			break
+		}
+	}
+	if commonDur == 0 {
+		return 0, fmt.Errorf("no common sample duration")
+	}
+	return commonDur, nil
+}
diff --git a/mp4/mediasegment.go b/mp4/mediasegment.go
index 8692ca0..05f12f3 100644
--- a/mp4/mediasegment.go
+++ b/mp4/mediasegment.go
@@ -1,6 +1,7 @@
 package mp4
 
 import (
+	"fmt"
 	"io"
 
 	"github.com/Eyevinn/mp4ff/bits"
@@ -194,3 +195,23 @@ func (s *MediaSegment) Fragmentify(timescale uint64, trex *TrexBox, duration uin
 	}
 	return outFragments, nil
 }
+
+// CommonSampleDuration returns a common non-zero sample duration for a track defined by trex if available.
+func (s *MediaSegment) CommonSampleDuration(trex *TrexBox) (uint32, error) {
+	if trex == nil {
+		return 0, fmt.Errorf("trex not set")
+	}
+	var commonDur uint32
+	for i, frag := range s.Fragments {
+		cDur, err := frag.CommonSampleDuration(trex)
+		if err != nil {
+			return 0, fmt.Errorf("fragment.CommonSampleDuration: %w", err)
+		}
+		if i == 0 {
+			commonDur = cDur
+		} else if commonDur != cDur {
+			return 0, fmt.Errorf("different common sample duration in fragment %d", i+1)
+		}
+	}
+	return commonDur, nil
+}
diff --git a/mp4/trun.go b/mp4/trun.go
index 7a5498a..4955f9f 100644
--- a/mp4/trun.go
+++ b/mp4/trun.go
@@ -423,7 +423,7 @@ func (t *TrunBox) AddSamples(s []Sample) {
 	t.Samples = append(t.Samples, s...)
 }
 
-// Duration - calculate total duration of all samples given defaultSampleDuration
+// Duration returns the total duration of all samples given defaultSampleDuration
 func (t *TrunBox) Duration(defaultSampleDuration uint32) uint64 {
 	if !t.HasSampleDuration() {
 		return uint64(defaultSampleDuration) * uint64(t.SampleCount())
@@ -435,6 +435,23 @@ func (t *TrunBox) Duration(defaultSampleDuration uint32) uint64 {
 	return total
 }
 
+// CommonSampleDuration returns the common duration if all samples have the same duration, otherwise 0.
+func (t *TrunBox) CommonSampleDuration(defaultSampleDuration uint32) uint32 {
+	if !t.HasSampleDuration() {
+		return defaultSampleDuration
+	}
+	if t.SampleCount() == 0 {
+		return 0
+	}
+	commonDur := t.Samples[0].Dur
+	for i := 1; i < int(t.SampleCount()); i++ {
+		if t.Samples[i].Dur != commonDur {
+			return 0
+		}
+	}
+	return commonDur
+}
+
 // GetSampleNrForRelativeTime - get sample number for exact relative time (calculated from summing durations)
 func (t *TrunBox) GetSampleNrForRelativeTime(deltaTime uint64, defaultSampleDuration uint32) (uint32, error) {
 	sampleCount := t.SampleCount()


Test patch:
diff --git a/mp4/mediasegment_test.go b/mp4/mediasegment_test.go
index afc27b0..6a02c5a 100644
--- a/mp4/mediasegment_test.go
+++ b/mp4/mediasegment_test.go
@@ -218,3 +218,48 @@ func TestDecodeEncodeCencFragmentedFile(t *testing.T) {
 		t.Errorf("generated bytes differ from input")
 	}
 }
+
+func TestCommonSampleDuration(t *testing.T) {
+	cases := []struct {
+		inFile          string
+		trackID         uint32
+		wantedCommonDur uint32
+		wantedError     string
+	}{
+		{"testdata/1.m4s", 2, 3000, ""},
+		{"testdata/golden_1_frag.m4s", 2, 3000, ""},
+		{"testdata/1.m4s", 1, 0, "fragment.CommonSampleDuration: no track with trex trackID=1"},
+	}
+	for _, c := range cases {
+		fd, err := os.Open(c.inFile)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer fd.Close()
+		f, err := DecodeFile(fd)
+		if err != nil {
+			t.Fatal(err)
+		}
+		trex := &TrexBox{
+			TrackID: c.trackID,
+		}
+		for _, s := range f.Segments {
+			gotCommonDur, err := s.CommonSampleDuration(trex)
+			if c.wantedError != "" {
+				if err == nil {
+					t.Errorf("case %s: wanted error %s, got nil", c.inFile, c.wantedError)
+				}
+				if err.Error() != c.wantedError {
+					t.Errorf("case %q: wanted error %q, got %q", c.inFile, c.wantedError, err.Error())
+				}
+				continue
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if gotCommonDur != c.wantedCommonDur {
+				t.Errorf("case %s: got commonDur %d, wanted %d", c.inFile, gotCommonDur, c.wantedCommonDur)
+			}
+		}
+	}
+}
diff --git a/mp4/trun_test.go b/mp4/trun_test.go
index 9241d0d..e84c2a0 100644
--- a/mp4/trun_test.go
+++ b/mp4/trun_test.go
@@ -153,3 +153,40 @@ func TestFirstSampleFlags(t *testing.T) {
 		t.Error("firstSampleFlags present after removal")
 	}
 }
+
+func TestCommonDuration(t *testing.T) {
+	cases := []struct {
+		commonDur       uint32
+		sampleDurs      []uint32
+		wantedCommonDur uint32
+	}{
+		{
+			commonDur:       1024,
+			sampleDurs:      []uint32{0, 0},
+			wantedCommonDur: 1024,
+		},
+		{
+			commonDur:       0,
+			sampleDurs:      []uint32{2048, 2048},
+			wantedCommonDur: 2048,
+		},
+		{
+			commonDur:       0,
+			sampleDurs:      []uint32{2047, 2049},
+			wantedCommonDur: 0,
+		},
+	}
+	for _, c := range cases {
+		trun := CreateTrun(0)
+		for _, s := range c.sampleDurs {
+			trun.AddSample(Sample{Dur: s})
+		}
+		if c.commonDur != 0 {
+			trun.Flags &= ^TrunSampleDurationPresentFlag
+		}
+		gotCommonDur := trun.CommonSampleDuration(c.commonDur)
+		if gotCommonDur != c.wantedCommonDur {
+			t.Errorf("got common duration %d instead of %d", gotCommonDur, c.wantedCommonDur)
+		}
+	}
+}