diff --git a/README.md b/README.md
index 241998e0..cc34278d 100644
--- a/README.md
+++ b/README.md
@@ -1348,7 +1348,7 @@ Where [mypath] is the name of a path. The server will return a list of timespans
The server provides an endpoint for downloading recordings:
```
-http://localhost:9996/get?path=[mypath]&start=[start_date]&duration=[duration]
+http://localhost:9996/get?path=[mypath]&start=[start_date]&duration=[duration]&format=[format]
```
Where:
@@ -1356,6 +1356,7 @@ Where:
* [mypath] is the path name
* [start_date] is the start date in [RFC3339 format](https://www.utctime.net/)
* [duration] is the maximum duration of the recording in seconds
+* [format] (optional) is the output format of the stream. Available values are "fmp4" (default) and "mp4"
All parameters must be [url-encoded](https://www.urlencoder.org/). For instance:
@@ -1371,6 +1372,12 @@ The resulting stream uses the fMP4 format, that is natively compatible with any
```
+The fMP4 format may offer limited compatibility with some players. It's possible to use the standard MP4 format by adding `format=mp4` to a `/get` request:
+
+```
+http://localhost:9996/get?path=[mypath]&start=[start_date]&duration=[duration]&format=mp4
+```
+
### Forward streams to other servers
To forward incoming streams to another server, use _FFmpeg_ inside the `runOnReady` parameter:
diff --git a/internal/playback/mp4/mp4_writer.go b/internal/playback/mp4/mp4_writer.go
new file mode 100644
index 00000000..098364e9
--- /dev/null
+++ b/internal/playback/mp4/mp4_writer.go
@@ -0,0 +1,83 @@
+package mp4
+
+import (
+ "io"
+
+ "github.com/abema/go-mp4"
+)
+
+type mp4Writer struct {
+ w *mp4.Writer
+}
+
+func newMP4Writer(w io.WriteSeeker) *mp4Writer {
+ return &mp4Writer{
+ w: mp4.NewWriter(w),
+ }
+}
+
+func (w *mp4Writer) writeBoxStart(box mp4.IImmutableBox) (int, error) {
+ bi := &mp4.BoxInfo{
+ Type: box.GetType(),
+ }
+ var err error
+ bi, err = w.w.StartBox(bi)
+ if err != nil {
+ return 0, err
+ }
+
+ _, err = mp4.Marshal(w.w, box, mp4.Context{})
+ if err != nil {
+ return 0, err
+ }
+
+ return int(bi.Offset), nil
+}
+
+func (w *mp4Writer) writeBoxEnd() error {
+ _, err := w.w.EndBox()
+ return err
+}
+
+func (w *mp4Writer) writeBox(box mp4.IImmutableBox) (int, error) {
+ off, err := w.writeBoxStart(box)
+ if err != nil {
+ return 0, err
+ }
+
+ err = w.writeBoxEnd()
+ if err != nil {
+ return 0, err
+ }
+
+ return off, nil
+}
+
+func (w *mp4Writer) rewriteBox(off int, box mp4.IImmutableBox) error {
+ prevOff, err := w.w.Seek(0, io.SeekCurrent)
+ if err != nil {
+ return err
+ }
+
+ _, err = w.w.Seek(int64(off), io.SeekStart)
+ if err != nil {
+ return err
+ }
+
+ _, err = w.writeBoxStart(box)
+ if err != nil {
+ return err
+ }
+
+ err = w.writeBoxEnd()
+ if err != nil {
+ return err
+ }
+
+ _, err = w.w.Seek(prevOff, io.SeekStart)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/playback/mp4/presentation.go b/internal/playback/mp4/presentation.go
new file mode 100644
index 00000000..fa01b6b3
--- /dev/null
+++ b/internal/playback/mp4/presentation.go
@@ -0,0 +1,208 @@
+// Package mp4 contains a MP4 muxer.
+package mp4
+
+import (
+ "io"
+ "time"
+
+ "github.com/abema/go-mp4"
+ "github.com/bluenviron/mediacommon/pkg/formats/fmp4/seekablebuffer"
+)
+
+const (
+ globalTimescale = 1000
+)
+
+func durationMp4ToGo(v int64, timeScale uint32) time.Duration {
+ timeScale64 := int64(timeScale)
+ secs := v / timeScale64
+ dec := v % timeScale64
+ return time.Duration(secs)*time.Second + time.Duration(dec)*time.Second/time.Duration(timeScale64)
+}
+
+// Presentation is timed sequence of video/audio samples.
+type Presentation struct {
+ Tracks []*Track
+}
+
+// Marshal encodes a Presentation.
+func (p *Presentation) Marshal(w io.Writer) error {
+ /*
+ |ftyp|
+ |moov|
+ | |mvhd|
+ | |trak|
+ | |trak|
+ | |....|
+ |mdat|
+ */
+
+ dataSize, sortedSamples := p.sortSamples()
+
+ err := p.marshalFtypAndMoov(w)
+ if err != nil {
+ return err
+ }
+
+ return p.marshalMdat(w, dataSize, sortedSamples)
+}
+
+func (p *Presentation) sortSamples() (uint32, []*Sample) {
+ sampleCount := 0
+ for _, track := range p.Tracks {
+ sampleCount += len(track.Samples)
+ }
+
+ processedSamples := make([]int, len(p.Tracks))
+ elapsed := make([]int64, len(p.Tracks))
+ offset := uint32(0)
+ sortedSamples := make([]*Sample, sampleCount)
+ pos := 0
+
+ for i, track := range p.Tracks {
+ elapsed[i] = int64(track.TimeOffset)
+ }
+
+ for {
+ bestTrack := -1
+ var bestElapsed time.Duration
+
+ for i, track := range p.Tracks {
+ if processedSamples[i] < len(track.Samples) {
+ elapsedGo := durationMp4ToGo(elapsed[i], track.TimeScale)
+
+ if bestTrack == -1 || elapsedGo < bestElapsed {
+ bestTrack = i
+ bestElapsed = elapsedGo
+ }
+ }
+ }
+
+ if bestTrack == -1 {
+ break
+ }
+
+ sample := p.Tracks[bestTrack].Samples[processedSamples[bestTrack]]
+ sample.offset = offset
+
+ processedSamples[bestTrack]++
+ elapsed[bestTrack] += int64(sample.Duration)
+ offset += sample.PayloadSize
+ sortedSamples[pos] = sample
+ pos++
+ }
+
+ return offset, sortedSamples
+}
+
+func (p *Presentation) marshalFtypAndMoov(w io.Writer) error {
+ var outBuf seekablebuffer.Buffer
+ mw := newMP4Writer(&outBuf)
+
+ _, err := mw.writeBox(&mp4.Ftyp{ //
+ MajorBrand: [4]byte{'i', 's', 'o', 'm'},
+ MinorVersion: 1,
+ CompatibleBrands: []mp4.CompatibleBrandElem{
+ {CompatibleBrand: [4]byte{'i', 's', 'o', 'm'}},
+ {CompatibleBrand: [4]byte{'i', 's', 'o', '2'}},
+ {CompatibleBrand: [4]byte{'m', 'p', '4', '1'}},
+ {CompatibleBrand: [4]byte{'m', 'p', '4', '2'}},
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ _, err = mw.writeBoxStart(&mp4.Moov{}) //
+ if err != nil {
+ return err
+ }
+
+ mvhd := &mp4.Mvhd{ //
+ Timescale: globalTimescale,
+ Rate: 65536,
+ Volume: 256,
+ Matrix: [9]int32{0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000},
+ NextTrackID: uint32(len(p.Tracks) + 1),
+ }
+ mvhdOffset, err := mw.writeBox(mvhd)
+ if err != nil {
+ return err
+ }
+
+ stcos := make([]*mp4.Stco, len(p.Tracks))
+ stcosOffsets := make([]int, len(p.Tracks))
+
+ for i, track := range p.Tracks {
+ res, err := track.marshal(mw)
+ if err != nil {
+ return err
+ }
+
+ stcos[i] = res.stco
+ stcosOffsets[i] = res.stcoOffset
+
+ if res.presentationDuration > mvhd.DurationV0 {
+ mvhd.DurationV0 = res.presentationDuration
+ }
+ }
+
+ err = mw.rewriteBox(mvhdOffset, mvhd)
+ if err != nil {
+ return err
+ }
+
+ err = mw.writeBoxEnd() //
+ if err != nil {
+ return err
+ }
+
+ moovEndOffset, err := outBuf.Seek(0, io.SeekCurrent)
+ if err != nil {
+ return err
+ }
+
+ dataOffset := moovEndOffset + 8
+
+ for i := range p.Tracks {
+ for j := range stcos[i].ChunkOffset {
+ stcos[i].ChunkOffset[j] += uint32(dataOffset)
+ }
+
+ err = mw.rewriteBox(stcosOffsets[i], stcos[i])
+ if err != nil {
+ return err
+ }
+ }
+
+ _, err = w.Write(outBuf.Bytes())
+ return err
+}
+
+func (p *Presentation) marshalMdat(w io.Writer, dataSize uint32, sortedSamples []*Sample) error {
+ mdatSize := uint32(8) + dataSize
+
+ _, err := w.Write([]byte{byte(mdatSize >> 24), byte(mdatSize >> 16), byte(mdatSize >> 8), byte(mdatSize)})
+ if err != nil {
+ return err
+ }
+
+ _, err = w.Write([]byte{'m', 'd', 'a', 't'})
+ if err != nil {
+ return err
+ }
+
+ for _, sa := range sortedSamples {
+ pl, err := sa.GetPayload()
+ if err != nil {
+ return err
+ }
+
+ _, err = w.Write(pl)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/playback/mp4/sample.go b/internal/playback/mp4/sample.go
new file mode 100644
index 00000000..58dbd66a
--- /dev/null
+++ b/internal/playback/mp4/sample.go
@@ -0,0 +1,12 @@
+package mp4
+
+// Sample is a sample of a Track.
+type Sample struct {
+ Duration uint32
+ PTSOffset int32
+ IsNonSyncSample bool
+ PayloadSize uint32
+ GetPayload func() ([]byte, error)
+
+ offset uint32 // filled by sortSamples
+}
diff --git a/internal/playback/mp4/track.go b/internal/playback/mp4/track.go
new file mode 100644
index 00000000..07dff1f8
--- /dev/null
+++ b/internal/playback/mp4/track.go
@@ -0,0 +1,1138 @@
+package mp4
+
+import (
+ "fmt"
+
+ "github.com/abema/go-mp4"
+ "github.com/bluenviron/mediacommon/pkg/codecs/av1"
+ "github.com/bluenviron/mediacommon/pkg/codecs/h264"
+ "github.com/bluenviron/mediacommon/pkg/codecs/h265"
+ "github.com/bluenviron/mediacommon/pkg/formats/fmp4"
+)
+
+// Specification: ISO 14496-1, Table 5
+const (
+ objectTypeIndicationVisualISO14496part2 = 0x20
+ objectTypeIndicationAudioISO14496part3 = 0x40
+ objectTypeIndicationVisualISO1318part2Main = 0x61
+ objectTypeIndicationAudioISO11172part3 = 0x6B
+ objectTypeIndicationVisualISO10918part1 = 0x6C
+)
+
+// Specification: ISO 14496-1, Table 6
+const (
+ streamTypeVisualStream = 0x04
+ streamTypeAudioStream = 0x05
+)
+
+func boolToUint8(v bool) uint8 {
+ if v {
+ return 1
+ }
+ return 0
+}
+
+func allSamplesAreSync(samples []*Sample) bool {
+ for _, sa := range samples {
+ if sa.IsNonSyncSample {
+ return false
+ }
+ }
+ return true
+}
+
+type headerTrackMarshalResult struct {
+ stco *mp4.Stco
+ stcoOffset int
+ presentationDuration uint32
+}
+
+// Track is a track of a Presentation.
+type Track struct {
+ ID int
+ TimeScale uint32
+ TimeOffset int32
+ Codec fmp4.Codec
+ Samples []*Sample
+}
+
+func (t *Track) marshal(w *mp4Writer) (*headerTrackMarshalResult, error) {
+ /*
+ |trak|
+ | |tkhd|
+ | |edts|
+ | | |elst|
+ | |mdia|
+ | | |mdhd|
+ | | |hdlr|
+ | | |minf|
+ | | | |vmhd| (video)
+ | | | |smhd| (audio)
+ | | | |dinf|
+ | | | | |dref|
+ | | | | | |url|
+ | | | |stbl|
+ | | | | |stsd|
+ | | | | | |av01| (AV1)
+ | | | | | | |av1C|
+ | | | | | |vp09| (VP9)
+ | | | | | | |vpcC|
+ | | | | | |hev1| (H265)
+ | | | | | | |hvcC|
+ | | | | | |avc1| (H264)
+ | | | | | | |avcC|
+ | | | | | |mp4v| (MPEG-4/2/1 video, MJPEG)
+ | | | | | | |esds|
+ | | | | | |Opus| (Opus)
+ | | | | | | |dOps|
+ | | | | | |mp4a| (MPEG-4/1 audio)
+ | | | | | | |esds|
+ | | | | | |ac-3| (AC-3)
+ | | | | | | |dac3|
+ | | | | | |ipcm| (LPCM)
+ | | | | | | |pcmC|
+ | | | | |stts|
+ | | | | |stss|
+ | | | | |ctts|
+ | | | | |stsc|
+ | | | | |stsz|
+ | | | | |stco|
+ */
+
+ _, err := w.writeBoxStart(&mp4.Trak{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ var av1SequenceHeader *av1.SequenceHeader
+ var h265SPS *h265.SPS
+ var h264SPS *h264.SPS
+
+ var width int
+ var height int
+
+ switch codec := t.Codec.(type) {
+ case *fmp4.CodecAV1:
+ av1SequenceHeader = &av1.SequenceHeader{}
+ err = av1SequenceHeader.Unmarshal(codec.SequenceHeader)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse AV1 sequence header: %w", err)
+ }
+
+ width = av1SequenceHeader.Width()
+ height = av1SequenceHeader.Height()
+
+ case *fmp4.CodecVP9:
+ if codec.Width == 0 {
+ return nil, fmt.Errorf("VP9 parameters not provided")
+ }
+
+ width = codec.Width
+ height = codec.Height
+
+ case *fmp4.CodecH265:
+ if len(codec.VPS) == 0 || len(codec.SPS) == 0 || len(codec.PPS) == 0 {
+ return nil, fmt.Errorf("H265 parameters not provided")
+ }
+
+ h265SPS = &h265.SPS{}
+ err = h265SPS.Unmarshal(codec.SPS)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse H265 SPS: %w", err)
+ }
+
+ width = h265SPS.Width()
+ height = h265SPS.Height()
+
+ case *fmp4.CodecH264:
+ if len(codec.SPS) == 0 || len(codec.PPS) == 0 {
+ return nil, fmt.Errorf("H264 parameters not provided")
+ }
+
+ h264SPS = &h264.SPS{}
+ err = h264SPS.Unmarshal(codec.SPS)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse H264 SPS: %w", err)
+ }
+
+ width = h264SPS.Width()
+ height = h264SPS.Height()
+
+ case *fmp4.CodecMPEG4Video:
+ if len(codec.Config) == 0 {
+ return nil, fmt.Errorf("MPEG-4 Video config not provided")
+ }
+
+ // TODO: parse config and use real values
+ width = 800
+ height = 600
+
+ case *fmp4.CodecMPEG1Video:
+ if len(codec.Config) == 0 {
+ return nil, fmt.Errorf("MPEG-1/2 Video config not provided")
+ }
+
+ // TODO: parse config and use real values
+ width = 800
+ height = 600
+
+ case *fmp4.CodecMJPEG:
+ if codec.Width == 0 {
+ return nil, fmt.Errorf("M-JPEG parameters not provided")
+ }
+
+ width = codec.Width
+ height = codec.Height
+ }
+
+ sampleDuration := uint32(0)
+ for _, sa := range t.Samples {
+ sampleDuration += sa.Duration
+ }
+
+ presentationDuration := uint32(((int64(sampleDuration) + int64(t.TimeOffset)) * globalTimescale) / int64(t.TimeScale))
+
+ if t.Codec.IsVideo() {
+ _, err = w.writeBox(&mp4.Tkhd{ //
+ FullBox: mp4.FullBox{
+ Flags: [3]byte{0, 0, 3},
+ },
+ TrackID: uint32(t.ID),
+ DurationV0: presentationDuration,
+ Width: uint32(width * 65536),
+ Height: uint32(height * 65536),
+ Matrix: [9]int32{0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000},
+ })
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ _, err = w.writeBox(&mp4.Tkhd{ //
+ FullBox: mp4.FullBox{
+ Flags: [3]byte{0, 0, 3},
+ },
+ TrackID: uint32(t.ID),
+ DurationV0: presentationDuration,
+ AlternateGroup: 1,
+ Volume: 256,
+ Matrix: [9]int32{0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000},
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ _, err = w.writeBoxStart(&mp4.Edts{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalELST(w, sampleDuration) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBoxStart(&mp4.Mdia{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Mdhd{ //
+ Timescale: t.TimeScale,
+ DurationV0: uint32(int64(sampleDuration) + int64(t.TimeOffset)),
+ Language: [3]byte{'u', 'n', 'd'},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if t.Codec.IsVideo() {
+ _, err = w.writeBox(&mp4.Hdlr{ //
+ HandlerType: [4]byte{'v', 'i', 'd', 'e'},
+ Name: "VideoHandler",
+ })
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ _, err = w.writeBox(&mp4.Hdlr{ //
+ HandlerType: [4]byte{'s', 'o', 'u', 'n'},
+ Name: "SoundHandler",
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ _, err = w.writeBoxStart(&mp4.Minf{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ if t.Codec.IsVideo() {
+ _, err = w.writeBox(&mp4.Vmhd{ //
+ FullBox: mp4.FullBox{
+ Flags: [3]byte{0, 0, 1},
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ _, err = w.writeBox(&mp4.Smhd{}) //
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ _, err = w.writeBoxStart(&mp4.Dinf{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBoxStart(&mp4.Dref{ //
+ EntryCount: 1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Url{ //
+ FullBox: mp4.FullBox{
+ Flags: [3]byte{0, 0, 1},
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBoxStart(&mp4.Stbl{}) //
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBoxStart(&mp4.Stsd{ //
+ EntryCount: 1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ switch codec := t.Codec.(type) {
+ case *fmp4.CodecAV1:
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeAv01(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ bs, err := av1.BitstreamMarshal([][]byte{codec.SequenceHeader})
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Av1C{ //
+ Marker: 1,
+ Version: 1,
+ SeqProfile: av1SequenceHeader.SeqProfile,
+ SeqLevelIdx0: av1SequenceHeader.SeqLevelIdx[0],
+ SeqTier0: boolToUint8(av1SequenceHeader.SeqTier[0]),
+ HighBitdepth: boolToUint8(av1SequenceHeader.ColorConfig.HighBitDepth),
+ TwelveBit: boolToUint8(av1SequenceHeader.ColorConfig.TwelveBit),
+ Monochrome: boolToUint8(av1SequenceHeader.ColorConfig.MonoChrome),
+ ChromaSubsamplingX: boolToUint8(av1SequenceHeader.ColorConfig.SubsamplingX),
+ ChromaSubsamplingY: boolToUint8(av1SequenceHeader.ColorConfig.SubsamplingY),
+ ChromaSamplePosition: uint8(av1SequenceHeader.ColorConfig.ChromaSamplePosition),
+ ConfigOBUs: bs,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecVP9:
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeVp09(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.VpcC{ //
+ FullBox: mp4.FullBox{
+ Version: 1,
+ },
+ Profile: codec.Profile,
+ Level: 10, // level 1
+ BitDepth: codec.BitDepth,
+ ChromaSubsampling: codec.ChromaSubsampling,
+ VideoFullRangeFlag: boolToUint8(codec.ColorRange),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecH265:
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeHev1(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.HvcC{ //
+ ConfigurationVersion: 1,
+ GeneralProfileIdc: h265SPS.ProfileTierLevel.GeneralProfileIdc,
+ GeneralProfileCompatibility: h265SPS.ProfileTierLevel.GeneralProfileCompatibilityFlag,
+ GeneralConstraintIndicator: [6]uint8{
+ codec.SPS[7], codec.SPS[8], codec.SPS[9],
+ codec.SPS[10], codec.SPS[11], codec.SPS[12],
+ },
+ GeneralLevelIdc: h265SPS.ProfileTierLevel.GeneralLevelIdc,
+ // MinSpatialSegmentationIdc
+ // ParallelismType
+ ChromaFormatIdc: uint8(h265SPS.ChromaFormatIdc),
+ BitDepthLumaMinus8: uint8(h265SPS.BitDepthLumaMinus8),
+ BitDepthChromaMinus8: uint8(h265SPS.BitDepthChromaMinus8),
+ // AvgFrameRate
+ // ConstantFrameRate
+ NumTemporalLayers: 1,
+ // TemporalIdNested
+ LengthSizeMinusOne: 3,
+ NumOfNaluArrays: 3,
+ NaluArrays: []mp4.HEVCNaluArray{
+ {
+ NaluType: byte(h265.NALUType_VPS_NUT),
+ NumNalus: 1,
+ Nalus: []mp4.HEVCNalu{{
+ Length: uint16(len(codec.VPS)),
+ NALUnit: codec.VPS,
+ }},
+ },
+ {
+ NaluType: byte(h265.NALUType_SPS_NUT),
+ NumNalus: 1,
+ Nalus: []mp4.HEVCNalu{{
+ Length: uint16(len(codec.SPS)),
+ NALUnit: codec.SPS,
+ }},
+ },
+ {
+ NaluType: byte(h265.NALUType_PPS_NUT),
+ NumNalus: 1,
+ Nalus: []mp4.HEVCNalu{{
+ Length: uint16(len(codec.PPS)),
+ NALUnit: codec.PPS,
+ }},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecH264:
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeAvc1(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.AVCDecoderConfiguration{ //
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeAvcC(),
+ },
+ ConfigurationVersion: 1,
+ Profile: h264SPS.ProfileIdc,
+ ProfileCompatibility: codec.SPS[2],
+ Level: h264SPS.LevelIdc,
+ LengthSizeMinusOne: 3,
+ NumOfSequenceParameterSets: 1,
+ SequenceParameterSets: []mp4.AVCParameterSet{
+ {
+ Length: uint16(len(codec.SPS)),
+ NALUnit: codec.SPS,
+ },
+ },
+ NumOfPictureParameterSets: 1,
+ PictureParameterSets: []mp4.AVCParameterSet{
+ {
+ Length: uint16(len(codec.PPS)),
+ NALUnit: codec.PPS,
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecMPEG4Video: //nolint:dupl
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeMp4v(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Esds{ //
+ Descriptors: []mp4.Descriptor{
+ {
+ Tag: mp4.ESDescrTag,
+ Size: 32 + uint32(len(codec.Config)),
+ ESDescriptor: &mp4.ESDescriptor{
+ ESID: uint16(t.ID),
+ },
+ },
+ {
+ Tag: mp4.DecoderConfigDescrTag,
+ Size: 18 + uint32(len(codec.Config)),
+ DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{
+ ObjectTypeIndication: objectTypeIndicationVisualISO14496part2,
+ StreamType: streamTypeVisualStream,
+ Reserved: true,
+ MaxBitrate: 1000000,
+ AvgBitrate: 1000000,
+ },
+ },
+ {
+ Tag: mp4.DecSpecificInfoTag,
+ Size: uint32(len(codec.Config)),
+ Data: codec.Config,
+ },
+ {
+ Tag: mp4.SLConfigDescrTag,
+ Size: 1,
+ Data: []byte{0x02},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecMPEG1Video: //nolint:dupl
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeMp4v(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Esds{ //
+ Descriptors: []mp4.Descriptor{
+ {
+ Tag: mp4.ESDescrTag,
+ Size: 32 + uint32(len(codec.Config)),
+ ESDescriptor: &mp4.ESDescriptor{
+ ESID: uint16(t.ID),
+ },
+ },
+ {
+ Tag: mp4.DecoderConfigDescrTag,
+ Size: 18 + uint32(len(codec.Config)),
+ DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{
+ ObjectTypeIndication: objectTypeIndicationVisualISO1318part2Main,
+ StreamType: streamTypeVisualStream,
+ Reserved: true,
+ MaxBitrate: 1000000,
+ AvgBitrate: 1000000,
+ },
+ },
+ {
+ Tag: mp4.DecSpecificInfoTag,
+ Size: uint32(len(codec.Config)),
+ Data: codec.Config,
+ },
+ {
+ Tag: mp4.SLConfigDescrTag,
+ Size: 1,
+ Data: []byte{0x02},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecMJPEG: //nolint:dupl
+ _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeMp4v(),
+ },
+ DataReferenceIndex: 1,
+ },
+ Width: uint16(width),
+ Height: uint16(height),
+ Horizresolution: 4718592,
+ Vertresolution: 4718592,
+ FrameCount: 1,
+ Depth: 24,
+ PreDefined3: -1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Esds{ //
+ Descriptors: []mp4.Descriptor{
+ {
+ Tag: mp4.ESDescrTag,
+ Size: 27,
+ ESDescriptor: &mp4.ESDescriptor{
+ ESID: uint16(t.ID),
+ },
+ },
+ {
+ Tag: mp4.DecoderConfigDescrTag,
+ Size: 13,
+ DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{
+ ObjectTypeIndication: objectTypeIndicationVisualISO10918part1,
+ StreamType: streamTypeVisualStream,
+ Reserved: true,
+ MaxBitrate: 1000000,
+ AvgBitrate: 1000000,
+ },
+ },
+ {
+ Tag: mp4.SLConfigDescrTag,
+ Size: 1,
+ Data: []byte{0x02},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecOpus:
+ _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeOpus(),
+ },
+ DataReferenceIndex: 1,
+ },
+ ChannelCount: uint16(codec.ChannelCount),
+ SampleSize: 16,
+ SampleRate: 48000 * 65536,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.DOps{ //
+ OutputChannelCount: uint8(codec.ChannelCount),
+ PreSkip: 312,
+ InputSampleRate: 48000,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecMPEG4Audio:
+ _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeMp4a(),
+ },
+ DataReferenceIndex: 1,
+ },
+ ChannelCount: uint16(codec.ChannelCount),
+ SampleSize: 16,
+ SampleRate: uint32(codec.SampleRate * 65536),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ enc, _ := codec.Config.Marshal()
+
+ _, err = w.writeBox(&mp4.Esds{ //
+ Descriptors: []mp4.Descriptor{
+ {
+ Tag: mp4.ESDescrTag,
+ Size: 32 + uint32(len(enc)),
+ ESDescriptor: &mp4.ESDescriptor{
+ ESID: uint16(t.ID),
+ },
+ },
+ {
+ Tag: mp4.DecoderConfigDescrTag,
+ Size: 18 + uint32(len(enc)),
+ DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{
+ ObjectTypeIndication: objectTypeIndicationAudioISO14496part3,
+ StreamType: streamTypeAudioStream,
+ Reserved: true,
+ MaxBitrate: 128825,
+ AvgBitrate: 128825,
+ },
+ },
+ {
+ Tag: mp4.DecSpecificInfoTag,
+ Size: uint32(len(enc)),
+ Data: enc,
+ },
+ {
+ Tag: mp4.SLConfigDescrTag,
+ Size: 1,
+ Data: []byte{0x02},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecMPEG1Audio:
+ _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeMp4a(),
+ },
+ DataReferenceIndex: 1,
+ },
+ ChannelCount: uint16(codec.ChannelCount),
+ SampleSize: 16,
+ SampleRate: uint32(codec.SampleRate * 65536),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Esds{ //
+ Descriptors: []mp4.Descriptor{
+ {
+ Tag: mp4.ESDescrTag,
+ Size: 27,
+ ESDescriptor: &mp4.ESDescriptor{
+ ESID: uint16(t.ID),
+ },
+ },
+ {
+ Tag: mp4.DecoderConfigDescrTag,
+ Size: 13,
+ DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{
+ ObjectTypeIndication: objectTypeIndicationAudioISO11172part3,
+ StreamType: streamTypeAudioStream,
+ Reserved: true,
+ MaxBitrate: 128825,
+ AvgBitrate: 128825,
+ },
+ },
+ {
+ Tag: mp4.SLConfigDescrTag,
+ Size: 1,
+ Data: []byte{0x02},
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecAC3:
+ _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeAC3(),
+ },
+ DataReferenceIndex: 1,
+ },
+ ChannelCount: uint16(codec.ChannelCount),
+ SampleSize: 16,
+ SampleRate: uint32(codec.SampleRate * 65536),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.Dac3{ //
+ Fscod: codec.Fscod,
+ Bsid: codec.Bsid,
+ Bsmod: codec.Bsmod,
+ Acmod: codec.Acmod,
+ LfeOn: func() uint8 {
+ if codec.LfeOn {
+ return 1
+ }
+ return 0
+ }(),
+ BitRateCode: codec.BitRateCode,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ case *fmp4.CodecLPCM:
+ _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ //
+ SampleEntry: mp4.SampleEntry{
+ AnyTypeBox: mp4.AnyTypeBox{
+ Type: mp4.BoxTypeIpcm(),
+ },
+ DataReferenceIndex: 1,
+ },
+ ChannelCount: uint16(codec.ChannelCount),
+ SampleSize: uint16(codec.BitDepth), // FFmpeg leaves this to 16 instead of using real bit depth
+ SampleRate: uint32(codec.SampleRate * 65536),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = w.writeBox(&mp4.PcmC{ //
+ FormatFlags: func() uint8 {
+ if codec.LittleEndian {
+ return 1
+ }
+ return 0
+ }(),
+ PCMSampleSize: uint8(codec.BitDepth),
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ err = w.writeBoxEnd() // *>
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalSTTS(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalSTSS(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalCTTS(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalSTSC(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = t.marshalSTSZ(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ stco, stcoOffset, err := t.marshalSTCO(w) //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.writeBoxEnd() //
+ if err != nil {
+ return nil, err
+ }
+
+ return &headerTrackMarshalResult{
+ stco: stco,
+ stcoOffset: stcoOffset,
+ presentationDuration: presentationDuration,
+ }, nil
+}
+
+func (t *Track) marshalELST(w *mp4Writer, sampleDuration uint32) error {
+ if t.TimeOffset > 0 {
+ _, err := w.writeBox(&mp4.Elst{
+ EntryCount: 2,
+ Entries: []mp4.ElstEntry{
+ { // pause
+ SegmentDurationV0: uint32((uint64(t.TimeOffset) * globalTimescale) / uint64(t.TimeScale)),
+ MediaTimeV0: -1,
+ MediaRateInteger: 1,
+ MediaRateFraction: 0,
+ },
+ { // presentation
+ SegmentDurationV0: uint32((uint64(sampleDuration) * globalTimescale) / uint64(t.TimeScale)),
+ MediaTimeV0: 0,
+ MediaRateInteger: 1,
+ MediaRateFraction: 0,
+ },
+ },
+ })
+ return err
+ }
+
+ _, err := w.writeBox(&mp4.Elst{
+ EntryCount: 1,
+ Entries: []mp4.ElstEntry{{
+ SegmentDurationV0: uint32(((uint64(sampleDuration) +
+ uint64(-t.TimeOffset)) * globalTimescale) / uint64(t.TimeScale)),
+ MediaTimeV0: -t.TimeOffset,
+ MediaRateInteger: 1,
+ MediaRateFraction: 0,
+ }},
+ })
+ return err
+}
+
+func (t *Track) marshalSTTS(w *mp4Writer) error {
+ entries := []mp4.SttsEntry{{
+ SampleCount: 1,
+ SampleDelta: t.Samples[0].Duration,
+ }}
+
+ for _, sa := range t.Samples[1:] {
+ if sa.Duration == entries[len(entries)-1].SampleDelta {
+ entries[len(entries)-1].SampleCount++
+ } else {
+ entries = append(entries, mp4.SttsEntry{
+ SampleCount: 1,
+ SampleDelta: sa.Duration,
+ })
+ }
+ }
+
+ _, err := w.writeBox(&mp4.Stts{
+ EntryCount: uint32(len(entries)),
+ Entries: entries,
+ })
+ return err
+}
+
+func (t *Track) marshalSTSS(w *mp4Writer) error {
+ if allSamplesAreSync(t.Samples) {
+ return nil
+ }
+
+ var sampleNumbers []uint32
+
+ for i, sa := range t.Samples {
+ if !sa.IsNonSyncSample {
+ sampleNumbers = append(sampleNumbers, uint32(i+1))
+ }
+ }
+
+ _, err := w.writeBox(&mp4.Stss{
+ EntryCount: uint32(len(sampleNumbers)),
+ SampleNumber: sampleNumbers,
+ })
+ return err
+}
+
+func (t *Track) marshalCTTS(w *mp4Writer) error {
+ entries := []mp4.CttsEntry{{
+ SampleCount: 1,
+ SampleOffsetV0: uint32(t.Samples[0].PTSOffset),
+ }}
+
+ for _, sa := range t.Samples[1:] {
+ if uint32(sa.PTSOffset) == entries[len(entries)-1].SampleOffsetV0 {
+ entries[len(entries)-1].SampleCount++
+ } else {
+ entries = append(entries, mp4.CttsEntry{
+ SampleCount: 1,
+ SampleOffsetV0: uint32(sa.PTSOffset),
+ })
+ }
+ }
+
+ _, err := w.writeBox(&mp4.Ctts{
+ FullBox: mp4.FullBox{
+ Version: 0,
+ },
+ EntryCount: uint32(len(entries)),
+ Entries: entries,
+ })
+ return err
+}
+
+func (t *Track) marshalSTSC(w *mp4Writer) error {
+ entries := []mp4.StscEntry{{
+ FirstChunk: 1,
+ SamplesPerChunk: 1,
+ SampleDescriptionIndex: 1,
+ }}
+
+ firstSample := t.Samples[0]
+ off := firstSample.offset + firstSample.PayloadSize
+
+ for _, sa := range t.Samples[1:] {
+ if sa.offset == off {
+ entries[len(entries)-1].SamplesPerChunk++
+ } else {
+ entries = append(entries, mp4.StscEntry{
+ FirstChunk: uint32(len(entries) + 1),
+ SamplesPerChunk: 1,
+ SampleDescriptionIndex: 1,
+ })
+ }
+
+ off = sa.offset + sa.PayloadSize
+ }
+
+ // further compression
+ for i := len(entries) - 1; i >= 1; i-- {
+ if entries[i].SamplesPerChunk == entries[i-1].SamplesPerChunk {
+ for j := i; j < len(entries)-1; j++ {
+ entries[j] = entries[j+1]
+ }
+ entries = entries[:len(entries)-1]
+ }
+ }
+
+ _, err := w.writeBox(&mp4.Stsc{
+ EntryCount: uint32(len(entries)),
+ Entries: entries,
+ })
+ return err
+}
+
+func (t *Track) marshalSTSZ(w *mp4Writer) error {
+ sampleSizes := make([]uint32, len(t.Samples))
+
+ for i, sa := range t.Samples {
+ sampleSizes[i] = sa.PayloadSize
+ }
+
+ _, err := w.writeBox(&mp4.Stsz{
+ SampleSize: 0,
+ SampleCount: uint32(len(sampleSizes)),
+ EntrySize: sampleSizes,
+ })
+ return err
+}
+
+func (t *Track) marshalSTCO(w *mp4Writer) (*mp4.Stco, int, error) {
+ firstSample := t.Samples[0]
+ off := firstSample.offset + firstSample.PayloadSize
+
+ entries := []uint32{firstSample.offset}
+
+ for _, sa := range t.Samples[1:] {
+ if sa.offset != off {
+ entries = append(entries, sa.offset)
+ }
+ off = sa.offset + sa.PayloadSize
+ }
+
+ stco := &mp4.Stco{
+ EntryCount: uint32(len(entries)),
+ ChunkOffset: entries,
+ }
+
+ offset, err := w.writeBox(stco)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return stco, offset, err
+}
diff --git a/internal/playback/muxer.go b/internal/playback/muxer.go
index a000b758..35c9faa8 100644
--- a/internal/playback/muxer.go
+++ b/internal/playback/muxer.go
@@ -5,7 +5,13 @@ import "github.com/bluenviron/mediacommon/pkg/formats/fmp4"
type muxer interface {
writeInit(init *fmp4.Init)
setTrack(trackID int)
- writeSample(dts int64, ptsOffset int32, isNonSyncSample bool, payload []byte) error
+ writeSample(
+ dts int64,
+ ptsOffset int32,
+ isNonSyncSample bool,
+ payloadSize uint32,
+ getPayload func() ([]byte, error),
+ ) error
writeFinalDTS(dts int64)
flush() error
}
diff --git a/internal/playback/muxer_fmp4.go b/internal/playback/muxer_fmp4.go
index 5e8685a2..a35e9660 100644
--- a/internal/playback/muxer_fmp4.go
+++ b/internal/playback/muxer_fmp4.go
@@ -9,7 +9,7 @@ import (
)
const (
- partSize = 1 * time.Second
+ partDuration = 1 * time.Second
)
type muxerFMP4Track struct {
@@ -57,12 +57,23 @@ func (w *muxerFMP4) setTrack(trackID int) {
w.curTrack = findTrack(w.tracks, trackID)
}
-func (w *muxerFMP4) writeSample(dts int64, ptsOffset int32, isNonSyncSample bool, payload []byte) error {
+func (w *muxerFMP4) writeSample(
+ dts int64,
+ ptsOffset int32,
+ isNonSyncSample bool,
+ _ uint32,
+ getPayload func() ([]byte, error),
+) error {
+ pl, err := getPayload()
+ if err != nil {
+ return err
+ }
+
if dts >= 0 {
if w.curTrack.firstDTS < 0 {
w.curTrack.firstDTS = dts
- // reset GOP preceding the first frame
+ // if frame is a IDR, remove previous GOP
if !isNonSyncSample {
w.curTrack.samples = nil
}
@@ -77,29 +88,30 @@ func (w *muxerFMP4) writeSample(dts int64, ptsOffset int32, isNonSyncSample bool
w.curTrack.samples = append(w.curTrack.samples, &fmp4.PartSample{
PTSOffset: ptsOffset,
IsNonSyncSample: isNonSyncSample,
- Payload: payload,
+ Payload: pl,
})
w.curTrack.lastDTS = dts
- partSizeMP4 := durationGoToMp4(partSize, w.curTrack.timeScale)
+ partDurationMP4 := durationGoToMp4(partDuration, w.curTrack.timeScale)
- if (w.curTrack.lastDTS - w.curTrack.firstDTS) > partSizeMP4 {
+ if (w.curTrack.lastDTS - w.curTrack.firstDTS) > partDurationMP4 {
err := w.innerFlush(false)
if err != nil {
return err
}
}
} else {
- // store GOP preceding the first frame, with PTSOffset = 0 and Duration = 0
- if !isNonSyncSample {
+ // store GOP of the first frame, and set PTSOffset = 0 and Duration = 0 in each sample
+ if !isNonSyncSample { // if frame is a IDR, reset GOP
w.curTrack.samples = []*fmp4.PartSample{{
IsNonSyncSample: isNonSyncSample,
- Payload: payload,
+ Payload: pl,
}}
} else {
+ // append frame to current GOP
w.curTrack.samples = append(w.curTrack.samples, &fmp4.PartSample{
IsNonSyncSample: isNonSyncSample,
- Payload: payload,
+ Payload: pl,
})
}
}
diff --git a/internal/playback/muxer_mp4.go b/internal/playback/muxer_mp4.go
new file mode 100644
index 00000000..af905c0b
--- /dev/null
+++ b/internal/playback/muxer_mp4.go
@@ -0,0 +1,105 @@
+package playback
+
+import (
+ "io"
+
+ "github.com/bluenviron/mediacommon/pkg/formats/fmp4"
+ "github.com/bluenviron/mediamtx/internal/playback/mp4"
+)
+
+type muxerMP4Track struct {
+ mp4.Track
+ lastDTS int64
+}
+
+func findTrackMP4(tracks []*muxerMP4Track, id int) *muxerMP4Track {
+ for _, track := range tracks {
+ if track.ID == id {
+ return track
+ }
+ }
+ return nil
+}
+
+type muxerMP4 struct {
+ w io.Writer
+
+ tracks []*muxerMP4Track
+ curTrack *muxerMP4Track
+}
+
+func (w *muxerMP4) writeInit(init *fmp4.Init) {
+ w.tracks = make([]*muxerMP4Track, len(init.Tracks))
+
+ for i, track := range init.Tracks {
+ w.tracks[i] = &muxerMP4Track{
+ Track: mp4.Track{
+ ID: track.ID,
+ TimeScale: track.TimeScale,
+ Codec: track.Codec,
+ },
+ }
+ }
+}
+
+func (w *muxerMP4) setTrack(trackID int) {
+ w.curTrack = findTrackMP4(w.tracks, trackID)
+}
+
+func (w *muxerMP4) writeSample(
+ dts int64,
+ ptsOffset int32,
+ isNonSyncSample bool,
+ payloadSize uint32,
+ getPayload func() ([]byte, error),
+) error {
+ // remove GOPs before the GOP of the first frame
+ if (dts < 0 || (dts >= 0 && w.curTrack.lastDTS < 0)) && !isNonSyncSample {
+ w.curTrack.Samples = nil
+ }
+
+ if w.curTrack.Samples == nil {
+ w.curTrack.TimeOffset = int32(dts)
+ } else {
+ diff := dts - w.curTrack.lastDTS
+ if diff < 0 {
+ diff = 0
+ }
+ w.curTrack.Samples[len(w.curTrack.Samples)-1].Duration = uint32(diff)
+ }
+
+ // prevent warning "edit list: 1 Missing key frame while searching for timestamp: 0"
+ if !isNonSyncSample {
+ ptsOffset = 0
+ }
+
+ w.curTrack.Samples = append(w.curTrack.Samples, &mp4.Sample{
+ PTSOffset: ptsOffset,
+ IsNonSyncSample: isNonSyncSample,
+ PayloadSize: payloadSize,
+ GetPayload: getPayload,
+ })
+ w.curTrack.lastDTS = dts
+
+ return nil
+}
+
+func (w *muxerMP4) writeFinalDTS(dts int64) {
+ diff := dts - w.curTrack.lastDTS
+ if diff < 0 {
+ diff = 0
+ }
+ w.curTrack.Samples[len(w.curTrack.Samples)-1].Duration = uint32(diff)
+}
+
+func (w *muxerMP4) flush() error {
+ h := mp4.Presentation{
+ Tracks: make([]*mp4.Track, len(w.tracks)),
+ }
+
+ for i, track := range w.tracks {
+ h.Tracks[i] = &track.Track
+ }
+
+ return h.Marshal(w.w)
+}
diff --git a/internal/playback/on_get.go b/internal/playback/on_get.go
index 5f41dbf0..dca27721 100644
--- a/internal/playback/on_get.go
+++ b/internal/playback/on_get.go
@@ -15,8 +15,6 @@ import (
"github.com/gin-gonic/gin"
)
-var errStopIteration = errors.New("stop iteration")
-
type writerWrapper struct {
ctx *gin.Context
written bool
@@ -52,69 +50,52 @@ func seekAndMux(
var firstInit *fmp4.Init
var segmentEnd time.Time
- err := func() error {
- f, err := os.Open(segments[0].Fpath)
+ f, err := os.Open(segments[0].Fpath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ firstInit, err = segmentFMP4ReadInit(f)
+ if err != nil {
+ return err
+ }
+
+ m.writeInit(firstInit)
+
+ segmentStartOffset := start.Sub(segments[0].Start)
+
+ segmentMaxElapsed, err := segmentFMP4SeekAndMuxParts(f, segmentStartOffset, duration, firstInit, m)
+ if err != nil {
+ return err
+ }
+
+ segmentEnd = start.Add(segmentMaxElapsed)
+
+ for _, seg := range segments[1:] {
+ f, err := os.Open(seg.Fpath)
if err != nil {
return err
}
defer f.Close()
- firstInit, err = segmentFMP4ReadInit(f)
+ init, err := segmentFMP4ReadInit(f)
if err != nil {
return err
}
- m.writeInit(firstInit)
+ if !segmentFMP4CanBeConcatenated(firstInit, segmentEnd, init, seg.Start) {
+ break
+ }
- segmentStartOffset := start.Sub(segments[0].Start)
+ segmentStartOffset := seg.Start.Sub(start)
- segmentMaxElapsed, err := segmentFMP4SeekAndMuxParts(f, segmentStartOffset, duration, firstInit, m)
+ segmentMaxElapsed, err := segmentFMP4MuxParts(f, segmentStartOffset, duration, firstInit, m)
if err != nil {
return err
}
segmentEnd = start.Add(segmentMaxElapsed)
-
- return nil
- }()
- if err != nil {
- return err
- }
-
- for _, seg := range segments[1:] {
- err := func() error {
- f, err := os.Open(seg.Fpath)
- if err != nil {
- return err
- }
- defer f.Close()
-
- init, err := segmentFMP4ReadInit(f)
- if err != nil {
- return err
- }
-
- if !segmentFMP4CanBeConcatenated(firstInit, segmentEnd, init, seg.Start) {
- return errStopIteration
- }
-
- segmentStartOffset := seg.Start.Sub(start)
-
- segmentMaxElapsed, err := segmentFMP4WriteParts(f, segmentStartOffset, duration, firstInit, m)
- if err != nil {
- return err
- }
-
- segmentEnd = start.Add(segmentMaxElapsed)
-
- return nil
- }()
- if err != nil {
- if errors.Is(err, errStopIteration) {
- break
- }
- return err
- }
}
err = m.flush()
@@ -147,8 +128,18 @@ func (p *Server) onGet(ctx *gin.Context) {
return
}
+ ww := &writerWrapper{ctx: ctx}
+ var m muxer
+
format := ctx.Query("format")
- if format != "" && format != "fmp4" {
+ switch format {
+ case "", "fmp4":
+ m = &muxerFMP4{w: ww}
+
+ case "mp4":
+ m = &muxerMP4{w: ww}
+
+ default:
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid format: %s", format))
return
}
@@ -169,10 +160,7 @@ func (p *Server) onGet(ctx *gin.Context) {
return
}
- ww := &writerWrapper{ctx: ctx}
- sw := &muxerFMP4{w: ww}
-
- err = seekAndMux(pathConf.RecordFormat, segments, start, duration, sw)
+ err = seekAndMux(pathConf.RecordFormat, segments, start, duration, m)
if err != nil {
// user aborted the download
var neterr *net.OpError
diff --git a/internal/playback/segment_fmp4.go b/internal/playback/segment_fmp4.go
index d565d831..6a612ab8 100644
--- a/internal/playback/segment_fmp4.go
+++ b/internal/playback/segment_fmp4.go
@@ -19,6 +19,12 @@ const (
var errTerminated = errors.New("terminated")
+type readSeekerAt interface {
+ io.Reader
+ io.Seeker
+ io.ReaderAt
+}
+
func durationGoToMp4(v time.Duration, timeScale uint32) int64 {
timeScale64 := int64(timeScale)
secs := v / time.Second
@@ -337,7 +343,7 @@ func segmentFMP4ReadMaxDuration(
}
func segmentFMP4SeekAndMuxParts(
- r io.ReadSeeker,
+ r readSeekerAt,
segmentStartOffset time.Duration,
duration time.Duration,
init *fmp4.Init,
@@ -394,12 +400,6 @@ func segmentFMP4SeekAndMuxParts(
trun := box.(*mp4.Trun)
dataOffset := moofOffset + uint64(trun.DataOffset)
-
- _, err = r.Seek(int64(dataOffset), io.SeekStart)
- if err != nil {
- return nil, err
- }
-
muxerDTS := int64(tfdt.BaseMediaDecodeTimeV1) - segmentStartOffsetMP4
atLeastOneSampleWritten := false
@@ -413,23 +413,33 @@ func segmentFMP4SeekAndMuxParts(
atLeastOnePartWritten = true
}
- payload := make([]byte, e.SampleSize)
- _, err := io.ReadFull(r, payload)
- if err != nil {
- return nil, err
- }
+ sampleOffset := dataOffset
+ sampleSize := e.SampleSize
err = m.writeSample(
muxerDTS,
e.SampleCompositionTimeOffsetV1,
(e.SampleFlags&sampleFlagIsNonSyncSample) != 0,
- payload,
+ e.SampleSize,
+ func() ([]byte, error) {
+ payload := make([]byte, sampleSize)
+ n, err := r.ReadAt(payload, int64(sampleOffset))
+ if err != nil {
+ return nil, err
+ }
+ if n != int(sampleSize) {
+ return nil, fmt.Errorf("partial read")
+ }
+
+ return payload, nil
+ },
)
if err != nil {
return nil, err
}
atLeastOneSampleWritten = true
+ dataOffset += uint64(e.SampleSize)
muxerDTS += int64(e.SampleDuration)
}
@@ -461,8 +471,8 @@ func segmentFMP4SeekAndMuxParts(
return maxMuxerDTS, nil
}
-func segmentFMP4WriteParts(
- r io.ReadSeeker,
+func segmentFMP4MuxParts(
+ r readSeekerAt,
segmentStartOffset time.Duration,
duration time.Duration,
init *fmp4.Init,
@@ -518,12 +528,6 @@ func segmentFMP4WriteParts(
trun := box.(*mp4.Trun)
dataOffset := moofOffset + uint64(trun.DataOffset)
-
- _, err = r.Seek(int64(dataOffset), io.SeekStart)
- if err != nil {
- return nil, err
- }
-
muxerDTS := int64(tfdt.BaseMediaDecodeTimeV1) + segmentStartOffsetMP4
atLeastOneSampleWritten := false
@@ -533,23 +537,33 @@ func segmentFMP4WriteParts(
break
}
- payload := make([]byte, e.SampleSize)
- _, err := io.ReadFull(r, payload)
- if err != nil {
- return nil, err
- }
+ sampleOffset := dataOffset
+ sampleSize := e.SampleSize
err = m.writeSample(
muxerDTS,
e.SampleCompositionTimeOffsetV1,
(e.SampleFlags&sampleFlagIsNonSyncSample) != 0,
- payload,
+ e.SampleSize,
+ func() ([]byte, error) {
+ payload := make([]byte, sampleSize)
+ n, err := r.ReadAt(payload, int64(sampleOffset))
+ if err != nil {
+ return nil, err
+ }
+ if n != int(sampleSize) {
+ return nil, fmt.Errorf("partial read")
+ }
+
+ return payload, nil
+ },
)
if err != nil {
return nil, err
}
atLeastOneSampleWritten = true
+ dataOffset += uint64(e.SampleSize)
muxerDTS += int64(e.SampleDuration)
}