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