Browse Source
* implement native recording (#1399) * support saving VP9 tracks * support saving MPEG-1 audio tracks * switch segment when codec parameters change * allow to disable recording on a path basis * allow disabling recording cleaner * support recording MPEG-1/2/4 video tracks * add microseconds to file names * add testspull/2355/head
22 changed files with 1685 additions and 51 deletions
@ -0,0 +1,738 @@
@@ -0,0 +1,738 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"fmt" |
||||
"path/filepath" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format" |
||||
"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/codecs/mpeg1audio" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4video" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/opus" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/vp9" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
|
||||
"github.com/bluenviron/mediamtx/internal/asyncwriter" |
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
"github.com/bluenviron/mediamtx/internal/stream" |
||||
"github.com/bluenviron/mediamtx/internal/unit" |
||||
) |
||||
|
||||
func durationGoToMp4(v time.Duration, timeScale uint32) uint64 { |
||||
timeScale64 := uint64(timeScale) |
||||
secs := v / time.Second |
||||
dec := v % time.Second |
||||
return uint64(secs)*timeScale64 + uint64(dec)*timeScale64/uint64(time.Second) |
||||
} |
||||
|
||||
func mpeg1audioChannelCount(cm mpeg1audio.ChannelMode) int { |
||||
switch cm { |
||||
case mpeg1audio.ChannelModeStereo, |
||||
mpeg1audio.ChannelModeJointStereo, |
||||
mpeg1audio.ChannelModeDualChannel: |
||||
return 2 |
||||
|
||||
default: |
||||
return 1 |
||||
} |
||||
} |
||||
|
||||
type sample struct { |
||||
*fmp4.PartSample |
||||
dts time.Duration |
||||
} |
||||
|
||||
// Agent saves streams on disk.
|
||||
type Agent struct { |
||||
path string |
||||
partDuration time.Duration |
||||
segmentDuration time.Duration |
||||
stream *stream.Stream |
||||
parent logger.Writer |
||||
|
||||
ctx context.Context |
||||
ctxCancel func() |
||||
writer *asyncwriter.Writer |
||||
tracks []*track |
||||
hasVideo bool |
||||
currentSegment *segment |
||||
|
||||
done chan struct{} |
||||
} |
||||
|
||||
// NewAgent allocates a nAgent.
|
||||
func NewAgent( |
||||
writeQueueSize int, |
||||
recordPath string, |
||||
partDuration time.Duration, |
||||
segmentDuration time.Duration, |
||||
pathName string, |
||||
stream *stream.Stream, |
||||
parent logger.Writer, |
||||
) *Agent { |
||||
recordPath, _ = filepath.Abs(recordPath) |
||||
recordPath = strings.ReplaceAll(recordPath, "%path", pathName) |
||||
recordPath += ".mp4" |
||||
|
||||
ctx, ctxCancel := context.WithCancel(context.Background()) |
||||
|
||||
r := &Agent{ |
||||
path: recordPath, |
||||
partDuration: partDuration, |
||||
segmentDuration: segmentDuration, |
||||
stream: stream, |
||||
parent: parent, |
||||
ctx: ctx, |
||||
ctxCancel: ctxCancel, |
||||
done: make(chan struct{}), |
||||
} |
||||
|
||||
r.writer = asyncwriter.New(writeQueueSize, r) |
||||
|
||||
nextID := 1 |
||||
|
||||
addTrack := func(codec fmp4.Codec) *track { |
||||
initTrack := &fmp4.InitTrack{ |
||||
TimeScale: 90000, |
||||
Codec: codec, |
||||
} |
||||
initTrack.ID = nextID |
||||
nextID++ |
||||
|
||||
track := newTrack(r, initTrack) |
||||
r.tracks = append(r.tracks, track) |
||||
|
||||
return track |
||||
} |
||||
|
||||
for _, media := range stream.Desc().Medias { |
||||
for _, forma := range media.Formats { |
||||
switch forma := forma.(type) { |
||||
case *format.AV1: |
||||
codec := &fmp4.CodecAV1{ |
||||
SequenceHeader: []byte{ |
||||
8, 0, 0, 0, 66, 167, 191, 228, 96, 13, 0, 64, |
||||
}, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
firstReceived := false |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.AV1) |
||||
if tunit.TU == nil { |
||||
return nil |
||||
} |
||||
|
||||
randomAccess := false |
||||
|
||||
for _, obu := range tunit.TU { |
||||
var h av1.OBUHeader |
||||
err := h.Unmarshal(obu) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if h.Type == av1.OBUTypeSequenceHeader { |
||||
if !bytes.Equal(codec.SequenceHeader, obu) { |
||||
codec.SequenceHeader = obu |
||||
r.updateCodecs() |
||||
} |
||||
randomAccess = true |
||||
} |
||||
} |
||||
|
||||
if !firstReceived { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
firstReceived = true |
||||
} |
||||
|
||||
sampl, err := fmp4.NewPartSampleAV1( |
||||
randomAccess, |
||||
tunit.TU) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: sampl, |
||||
dts: tunit.PTS, |
||||
}) |
||||
}) |
||||
|
||||
case *format.VP9: |
||||
codec := &fmp4.CodecVP9{ |
||||
Width: 1280, |
||||
Height: 720, |
||||
Profile: 1, |
||||
BitDepth: 8, |
||||
ChromaSubsampling: 1, |
||||
ColorRange: false, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
firstReceived := false |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.VP9) |
||||
if tunit.Frame == nil { |
||||
return nil |
||||
} |
||||
|
||||
var h vp9.Header |
||||
err := h.Unmarshal(tunit.Frame) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
randomAccess := false |
||||
|
||||
if h.FrameType == vp9.FrameTypeKeyFrame { |
||||
randomAccess = true |
||||
|
||||
if w := h.Width(); codec.Width != w { |
||||
codec.Width = w |
||||
r.updateCodecs() |
||||
} |
||||
if h := h.Width(); codec.Height != h { |
||||
codec.Height = h |
||||
r.updateCodecs() |
||||
} |
||||
if codec.Profile != h.Profile { |
||||
codec.Profile = h.Profile |
||||
r.updateCodecs() |
||||
} |
||||
if codec.BitDepth != h.ColorConfig.BitDepth { |
||||
codec.BitDepth = h.ColorConfig.BitDepth |
||||
r.updateCodecs() |
||||
} |
||||
if c := h.ChromaSubsampling(); codec.ChromaSubsampling != c { |
||||
codec.ChromaSubsampling = c |
||||
r.updateCodecs() |
||||
} |
||||
if codec.ColorRange != h.ColorConfig.ColorRange { |
||||
codec.ColorRange = h.ColorConfig.ColorRange |
||||
r.updateCodecs() |
||||
} |
||||
} |
||||
|
||||
if !firstReceived { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
firstReceived = true |
||||
} |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
IsNonSyncSample: !randomAccess, |
||||
Payload: tunit.Frame, |
||||
}, |
||||
dts: tunit.PTS, |
||||
}) |
||||
}) |
||||
|
||||
case *format.VP8: |
||||
// TODO
|
||||
|
||||
case *format.H265: |
||||
vps, sps, pps := forma.SafeParams() |
||||
|
||||
if vps == nil || sps == nil || pps == nil { |
||||
vps = []byte{ |
||||
0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x02, 0x20, |
||||
0x00, 0x00, 0x03, 0x00, 0xb0, 0x00, 0x00, 0x03, |
||||
0x00, 0x00, 0x03, 0x00, 0x7b, 0x18, 0xb0, 0x24, |
||||
} |
||||
|
||||
sps = []byte{ |
||||
0x42, 0x01, 0x01, 0x02, 0x20, 0x00, 0x00, 0x03, |
||||
0x00, 0xb0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, |
||||
0x00, 0x7b, 0xa0, 0x07, 0x82, 0x00, 0x88, 0x7d, |
||||
0xb6, 0x71, 0x8b, 0x92, 0x44, 0x80, 0x53, 0x88, |
||||
0x88, 0x92, 0xcf, 0x24, 0xa6, 0x92, 0x72, 0xc9, |
||||
0x12, 0x49, 0x22, 0xdc, 0x91, 0xaa, 0x48, 0xfc, |
||||
0xa2, 0x23, 0xff, 0x00, 0x01, 0x00, 0x01, 0x6a, |
||||
0x02, 0x02, 0x02, 0x01, |
||||
} |
||||
|
||||
pps = []byte{ |
||||
0x44, 0x01, 0xc0, 0x25, 0x2f, 0x05, 0x32, 0x40, |
||||
} |
||||
} |
||||
|
||||
codec := &fmp4.CodecH265{ |
||||
VPS: vps, |
||||
SPS: sps, |
||||
PPS: pps, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
var dtsExtractor *h265.DTSExtractor |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.H265) |
||||
if tunit.AU == nil { |
||||
return nil |
||||
} |
||||
|
||||
randomAccess := false |
||||
|
||||
for _, nalu := range tunit.AU { |
||||
typ := h265.NALUType((nalu[0] >> 1) & 0b111111) |
||||
|
||||
switch typ { |
||||
case h265.NALUType_VPS_NUT: |
||||
if !bytes.Equal(codec.VPS, nalu) { |
||||
codec.VPS = nalu |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
case h265.NALUType_SPS_NUT: |
||||
if !bytes.Equal(codec.SPS, nalu) { |
||||
codec.SPS = nalu |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
case h265.NALUType_PPS_NUT: |
||||
if !bytes.Equal(codec.PPS, nalu) { |
||||
codec.PPS = nalu |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
case h265.NALUType_IDR_W_RADL, h265.NALUType_IDR_N_LP, h265.NALUType_CRA_NUT: |
||||
randomAccess = true |
||||
} |
||||
} |
||||
|
||||
if dtsExtractor == nil { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
dtsExtractor = h265.NewDTSExtractor() |
||||
} |
||||
|
||||
dts, err := dtsExtractor.Extract(tunit.AU, tunit.PTS) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
sampl, err := fmp4.NewPartSampleH26x( |
||||
int32(durationGoToMp4(tunit.PTS-dts, 90000)), |
||||
randomAccess, |
||||
tunit.AU) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: sampl, |
||||
dts: dts, |
||||
}) |
||||
}) |
||||
|
||||
case *format.H264: |
||||
sps, pps := forma.SafeParams() |
||||
|
||||
if sps == nil || pps == nil { |
||||
sps = []byte{ |
||||
0x67, 0x42, 0xc0, 0x1f, 0xd9, 0x00, 0xf0, 0x11, |
||||
0x7e, 0xf0, 0x11, 0x00, 0x00, 0x03, 0x00, 0x01, |
||||
0x00, 0x00, 0x03, 0x00, 0x30, 0x8f, 0x18, 0x32, |
||||
0x48, |
||||
} |
||||
|
||||
pps = []byte{ |
||||
0x68, 0xcb, 0x8c, 0xb2, |
||||
} |
||||
} |
||||
|
||||
codec := &fmp4.CodecH264{ |
||||
SPS: sps, |
||||
PPS: pps, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
var dtsExtractor *h264.DTSExtractor |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.H264) |
||||
if tunit.AU == nil { |
||||
return nil |
||||
} |
||||
|
||||
randomAccess := false |
||||
|
||||
for _, nalu := range tunit.AU { |
||||
typ := h264.NALUType(nalu[0] & 0x1F) |
||||
switch typ { |
||||
case h264.NALUTypeSPS: |
||||
if !bytes.Equal(codec.SPS, nalu) { |
||||
codec.SPS = nalu |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
case h264.NALUTypePPS: |
||||
if !bytes.Equal(codec.PPS, nalu) { |
||||
codec.PPS = nalu |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
case h264.NALUTypeIDR: |
||||
randomAccess = true |
||||
} |
||||
} |
||||
|
||||
if dtsExtractor == nil { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
dtsExtractor = h264.NewDTSExtractor() |
||||
} |
||||
|
||||
dts, err := dtsExtractor.Extract(tunit.AU, tunit.PTS) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
sampl, err := fmp4.NewPartSampleH26x( |
||||
int32(durationGoToMp4(tunit.PTS-dts, 90000)), |
||||
randomAccess, |
||||
tunit.AU) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: sampl, |
||||
dts: dts, |
||||
}) |
||||
}) |
||||
|
||||
case *format.MPEG4Video: |
||||
config := forma.SafeParams() |
||||
|
||||
if config == nil { |
||||
config = []byte{ |
||||
0x00, 0x00, 0x01, 0xb0, 0x01, 0x00, 0x00, 0x01, |
||||
0xb5, 0x89, 0x13, 0x00, 0x00, 0x01, 0x00, 0x00, |
||||
0x00, 0x01, 0x20, 0x00, 0xc4, 0x8d, 0x88, 0x00, |
||||
0xf5, 0x3c, 0x04, 0x87, 0x14, 0x63, 0x00, 0x00, |
||||
0x01, 0xb2, 0x4c, 0x61, 0x76, 0x63, 0x35, 0x38, |
||||
0x2e, 0x31, 0x33, 0x34, 0x2e, 0x31, 0x30, 0x30, |
||||
} |
||||
} |
||||
|
||||
codec := &fmp4.CodecMPEG4Video{ |
||||
Config: config, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
firstReceived := false |
||||
var lastPTS time.Duration |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.MPEG4Video) |
||||
if tunit.Frame == nil { |
||||
return nil |
||||
} |
||||
|
||||
randomAccess := bytes.Contains(tunit.Frame, []byte{0, 0, 1, byte(mpeg4video.GroupOfVOPStartCode)}) |
||||
|
||||
if bytes.HasPrefix(tunit.Frame, []byte{0, 0, 1, byte(mpeg4video.VisualObjectSequenceStartCode)}) { |
||||
end := bytes.Index(tunit.Frame[4:], []byte{0, 0, 1, byte(mpeg4video.GroupOfVOPStartCode)}) |
||||
if end >= 0 { |
||||
config := tunit.Frame[:end+4] |
||||
|
||||
if !bytes.Equal(codec.Config, config) { |
||||
codec.Config = config |
||||
r.updateCodecs() |
||||
} |
||||
} |
||||
} |
||||
|
||||
if !firstReceived { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
firstReceived = true |
||||
} else if tunit.PTS < lastPTS { |
||||
return fmt.Errorf("MPEG-4 Video streams with B-frames are not supported (yet)") |
||||
} |
||||
lastPTS = tunit.PTS |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: tunit.Frame, |
||||
IsNonSyncSample: !randomAccess, |
||||
}, |
||||
dts: tunit.PTS, |
||||
}) |
||||
}) |
||||
|
||||
case *format.MPEG1Video: |
||||
codec := &fmp4.CodecMPEG1Video{ |
||||
Config: []byte{ |
||||
0x00, 0x00, 0x01, 0xb3, 0x78, 0x04, 0x38, 0x35, |
||||
0xff, 0xff, 0xe0, 0x18, 0x00, 0x00, 0x01, 0xb5, |
||||
0x14, 0x4a, 0x00, 0x01, 0x00, 0x00, |
||||
}, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
firstReceived := false |
||||
var lastPTS time.Duration |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.MPEG1Video) |
||||
if tunit.Frame == nil { |
||||
return nil |
||||
} |
||||
|
||||
randomAccess := bytes.Contains(tunit.Frame, []byte{0, 0, 1, 0xB8}) |
||||
|
||||
if bytes.HasPrefix(tunit.Frame, []byte{0, 0, 1, 0xB3}) { |
||||
end := bytes.Index(tunit.Frame[4:], []byte{0, 0, 1, 0xB8}) |
||||
if end >= 0 { |
||||
config := tunit.Frame[:end+4] |
||||
|
||||
if !bytes.Equal(codec.Config, config) { |
||||
codec.Config = config |
||||
r.updateCodecs() |
||||
} |
||||
} |
||||
} |
||||
|
||||
if !firstReceived { |
||||
if !randomAccess { |
||||
return nil |
||||
} |
||||
firstReceived = true |
||||
} else if tunit.PTS < lastPTS { |
||||
return fmt.Errorf("MPEG-1 Video streams with B-frames are not supported (yet)") |
||||
} |
||||
lastPTS = tunit.PTS |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: tunit.Frame, |
||||
IsNonSyncSample: !randomAccess, |
||||
}, |
||||
dts: tunit.PTS, |
||||
}) |
||||
}) |
||||
|
||||
case *format.MJPEG: |
||||
// TODO
|
||||
|
||||
case *format.Opus: |
||||
codec := &fmp4.CodecOpus{ |
||||
ChannelCount: func() int { |
||||
if forma.IsStereo { |
||||
return 2 |
||||
} |
||||
return 1 |
||||
}(), |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.Opus) |
||||
if tunit.Packets == nil { |
||||
return nil |
||||
} |
||||
|
||||
pts := tunit.PTS |
||||
|
||||
for _, packet := range tunit.Packets { |
||||
err := track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: packet, |
||||
}, |
||||
dts: pts, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
pts += opus.PacketDuration(packet) |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
case *format.MPEG4AudioGeneric: |
||||
codec := &fmp4.CodecMPEG4Audio{ |
||||
Config: *forma.Config, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
sampleRate := time.Duration(forma.Config.SampleRate) |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.MPEG4AudioGeneric) |
||||
if tunit.AUs == nil { |
||||
return nil |
||||
} |
||||
|
||||
for i, au := range tunit.AUs { |
||||
auPTS := tunit.PTS + time.Duration(i)*mpeg4audio.SamplesPerAccessUnit* |
||||
time.Second/sampleRate |
||||
|
||||
err := track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: au, |
||||
}, |
||||
dts: auPTS, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
case *format.MPEG4AudioLATM: |
||||
codec := &fmp4.CodecMPEG4Audio{ |
||||
Config: *forma.Config.Programs[0].Layers[0].AudioSpecificConfig, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.MPEG4AudioLATM) |
||||
if tunit.AU == nil { |
||||
return nil |
||||
} |
||||
|
||||
return track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: tunit.AU, |
||||
}, |
||||
dts: tunit.PTS, |
||||
}) |
||||
}) |
||||
|
||||
case *format.MPEG1Audio: |
||||
codec := &fmp4.CodecMPEG1Audio{ |
||||
SampleRate: 32000, |
||||
ChannelCount: 2, |
||||
} |
||||
track := addTrack(codec) |
||||
|
||||
stream.AddReader(r.writer, media, forma, func(u unit.Unit) error { |
||||
tunit := u.(*unit.MPEG1Audio) |
||||
if tunit.Frames == nil { |
||||
return nil |
||||
} |
||||
|
||||
pts := tunit.PTS |
||||
|
||||
for _, frame := range tunit.Frames { |
||||
var h mpeg1audio.FrameHeader |
||||
err := h.Unmarshal(frame) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if codec.SampleRate != h.SampleRate { |
||||
codec.SampleRate = h.SampleRate |
||||
r.updateCodecs() |
||||
} |
||||
if c := mpeg1audioChannelCount(h.ChannelMode); codec.ChannelCount != c { |
||||
codec.ChannelCount = c |
||||
r.updateCodecs() |
||||
} |
||||
|
||||
err = track.record(&sample{ |
||||
PartSample: &fmp4.PartSample{ |
||||
Payload: frame, |
||||
}, |
||||
dts: pts, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
pts += time.Duration(h.SampleCount()) * |
||||
time.Second / time.Duration(h.SampleRate) |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
case *format.G722: |
||||
// TODO
|
||||
|
||||
case *format.G711: |
||||
// TODO
|
||||
|
||||
case *format.LPCM: |
||||
// TODO
|
||||
} |
||||
} |
||||
} |
||||
|
||||
r.Log(logger.Info, "recording %d %s", |
||||
len(r.tracks), |
||||
func() string { |
||||
if len(r.tracks) == 1 { |
||||
return "track" |
||||
} |
||||
return "tracks" |
||||
}()) |
||||
|
||||
go r.run() |
||||
|
||||
return r |
||||
} |
||||
|
||||
// Close closes the Agent.
|
||||
func (r *Agent) Close() { |
||||
r.ctxCancel() |
||||
<-r.done |
||||
} |
||||
|
||||
// Log is the main logging function.
|
||||
func (r *Agent) Log(level logger.Level, format string, args ...interface{}) { |
||||
r.parent.Log(level, "[record] "+format, args...) |
||||
} |
||||
|
||||
func (r *Agent) run() { |
||||
close(r.done) |
||||
|
||||
r.writer.Start() |
||||
|
||||
select { |
||||
case err := <-r.writer.Error(): |
||||
r.Log(logger.Error, err.Error()) |
||||
r.stream.RemoveReader(r.writer) |
||||
|
||||
case <-r.ctx.Done(): |
||||
r.stream.RemoveReader(r.writer) |
||||
r.writer.Stop() |
||||
} |
||||
|
||||
if r.currentSegment != nil { |
||||
r.currentSegment.close() //nolint:errcheck
|
||||
} |
||||
} |
||||
|
||||
func (r *Agent) updateCodecs() { |
||||
// if codec parameters have been updated,
|
||||
// and current segment has already written codec parameters on disk,
|
||||
// close current segment.
|
||||
if r.currentSegment != nil && r.currentSegment.f != nil { |
||||
r.currentSegment.close() //nolint:errcheck
|
||||
r.currentSegment = nil |
||||
} |
||||
} |
@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/description" |
||||
"github.com/bluenviron/gortsplib/v4/pkg/format" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h265" |
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio" |
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/bluenviron/mediamtx/internal/stream" |
||||
"github.com/bluenviron/mediamtx/internal/unit" |
||||
) |
||||
|
||||
type nilLogger struct{} |
||||
|
||||
func (nilLogger) Log(_ logger.Level, _ string, _ ...interface{}) { |
||||
} |
||||
|
||||
func TestAgent(t *testing.T) { |
||||
n := 0 |
||||
timeNow = func() time.Time { |
||||
n++ |
||||
if n >= 2 { |
||||
return time.Date(2008, 0o5, 20, 22, 15, 25, 125000, time.UTC) |
||||
} |
||||
return time.Date(2009, 0o5, 20, 22, 15, 25, 427000, time.UTC) |
||||
} |
||||
|
||||
desc := &description.Session{Medias: []*description.Media{ |
||||
{ |
||||
Type: description.MediaTypeVideo, |
||||
Formats: []format.Format{&format.H265{ |
||||
PayloadTyp: 96, |
||||
}}, |
||||
}, |
||||
{ |
||||
Type: description.MediaTypeVideo, |
||||
Formats: []format.Format{&format.H264{ |
||||
PayloadTyp: 96, |
||||
PacketizationMode: 1, |
||||
}}, |
||||
}, |
||||
{ |
||||
Type: description.MediaTypeAudio, |
||||
Formats: []format.Format{&format.MPEG4Audio{ |
||||
PayloadTyp: 96, |
||||
Config: &mpeg4audio.Config{ |
||||
Type: 2, |
||||
SampleRate: 44100, |
||||
ChannelCount: 2, |
||||
}, |
||||
SizeLength: 13, |
||||
IndexLength: 3, |
||||
IndexDeltaLength: 3, |
||||
}}, |
||||
}, |
||||
}} |
||||
|
||||
stream, err := stream.New( |
||||
1460, |
||||
desc, |
||||
true, |
||||
&nilLogger{}, |
||||
) |
||||
require.NoError(t, err) |
||||
defer stream.Close() |
||||
|
||||
dir, err := os.MkdirTemp("", "mediamtx-agent") |
||||
require.NoError(t, err) |
||||
defer os.RemoveAll(dir) |
||||
|
||||
recordPath := filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f") |
||||
|
||||
a := NewAgent( |
||||
1024, |
||||
recordPath, |
||||
100*time.Millisecond, |
||||
1*time.Second, |
||||
"mypath", |
||||
stream, |
||||
&nilLogger{}, |
||||
) |
||||
|
||||
for i := 0; i < 3; i++ { |
||||
stream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.H265{ |
||||
Base: unit.Base{ |
||||
PTS: (50 + time.Duration(i)) * time.Second, |
||||
}, |
||||
AU: [][]byte{ |
||||
{ // VPS
|
||||
0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x02, 0x20, |
||||
0x00, 0x00, 0x03, 0x00, 0xb0, 0x00, 0x00, 0x03, |
||||
0x00, 0x00, 0x03, 0x00, 0x7b, 0x18, 0xb0, 0x24, |
||||
}, |
||||
{ // SPS
|
||||
0x42, 0x01, 0x01, 0x02, 0x20, 0x00, 0x00, 0x03, |
||||
0x00, 0xb0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, |
||||
0x00, 0x7b, 0xa0, 0x07, 0x82, 0x00, 0x88, 0x7d, |
||||
0xb6, 0x71, 0x8b, 0x92, 0x44, 0x80, 0x53, 0x88, |
||||
0x88, 0x92, 0xcf, 0x24, 0xa6, 0x92, 0x72, 0xc9, |
||||
0x12, 0x49, 0x22, 0xdc, 0x91, 0xaa, 0x48, 0xfc, |
||||
0xa2, 0x23, 0xff, 0x00, 0x01, 0x00, 0x01, 0x6a, |
||||
0x02, 0x02, 0x02, 0x01, |
||||
}, |
||||
{ // PPS
|
||||
0x44, 0x01, 0xc0, 0x25, 0x2f, 0x05, 0x32, 0x40, |
||||
}, |
||||
{byte(h265.NALUType_CRA_NUT) << 1, 0}, // IDR
|
||||
}, |
||||
}) |
||||
|
||||
stream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.H264{ |
||||
Base: unit.Base{ |
||||
PTS: (50 + time.Duration(i)) * time.Second, |
||||
}, |
||||
AU: [][]byte{ |
||||
{ // SPS
|
||||
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, |
||||
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, |
||||
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20, |
||||
}, |
||||
{ // PPS
|
||||
0x08, 0x06, 0x07, 0x08, |
||||
}, |
||||
{5}, // IDR
|
||||
}, |
||||
}) |
||||
|
||||
stream.WriteUnit(desc.Medias[2], desc.Medias[2].Formats[0], &unit.MPEG4AudioGeneric{ |
||||
Base: unit.Base{ |
||||
PTS: (50 + time.Duration(i)) * time.Second, |
||||
}, |
||||
AUs: [][]byte{{1, 2, 3, 4}}, |
||||
}) |
||||
} |
||||
|
||||
time.Sleep(500 * time.Millisecond) |
||||
a.Close() |
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "mypath", "2008-05-20_22-15-25-000125.mp4")) |
||||
require.NoError(t, err) |
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "mypath", "2009-05-20_22-15-25-000427.mp4")) |
||||
require.NoError(t, err) |
||||
} |
@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"context" |
||||
"io/fs" |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
) |
||||
|
||||
func commonPath(v string) string { |
||||
common := "" |
||||
remaining := v |
||||
|
||||
for { |
||||
i := strings.IndexAny(remaining, "\\/") |
||||
if i < 0 { |
||||
break |
||||
} |
||||
|
||||
var part string |
||||
part, remaining = remaining[:i+1], remaining[i+1:] |
||||
|
||||
if strings.Contains(part, "%") { |
||||
break |
||||
} |
||||
|
||||
common += part |
||||
} |
||||
|
||||
if len(common) > 0 { |
||||
common = common[:len(common)-1] |
||||
} |
||||
|
||||
return common |
||||
} |
||||
|
||||
// Cleaner removes expired recordings from disk.
|
||||
type Cleaner struct { |
||||
ctx context.Context |
||||
ctxCancel func() |
||||
path string |
||||
deleteAfter time.Duration |
||||
parent logger.Writer |
||||
|
||||
done chan struct{} |
||||
} |
||||
|
||||
// NewCleaner allocates a Cleaner.
|
||||
func NewCleaner( |
||||
recordPath string, |
||||
deleteAfter time.Duration, |
||||
parent logger.Writer, |
||||
) *Cleaner { |
||||
recordPath, _ = filepath.Abs(recordPath) |
||||
recordPath += ".mp4" |
||||
|
||||
ctx, ctxCancel := context.WithCancel(context.Background()) |
||||
|
||||
c := &Cleaner{ |
||||
ctx: ctx, |
||||
ctxCancel: ctxCancel, |
||||
path: recordPath, |
||||
deleteAfter: deleteAfter, |
||||
parent: parent, |
||||
done: make(chan struct{}), |
||||
} |
||||
|
||||
go c.run() |
||||
|
||||
return c |
||||
} |
||||
|
||||
// Close closes the Cleaner.
|
||||
func (c *Cleaner) Close() { |
||||
c.ctxCancel() |
||||
<-c.done |
||||
} |
||||
|
||||
// Log is the main logging function.
|
||||
func (c *Cleaner) Log(level logger.Level, format string, args ...interface{}) { |
||||
c.parent.Log(level, "[record cleaner]"+format, args...) |
||||
} |
||||
|
||||
func (c *Cleaner) run() { |
||||
defer close(c.done) |
||||
|
||||
interval := 30 * 60 * time.Second |
||||
if interval > (c.deleteAfter / 2) { |
||||
interval = c.deleteAfter / 2 |
||||
} |
||||
|
||||
c.doRun() //nolint:errcheck
|
||||
|
||||
for { |
||||
select { |
||||
case <-time.After(interval): |
||||
c.doRun() //nolint:errcheck
|
||||
|
||||
case <-c.ctx.Done(): |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (c *Cleaner) doRun() error { |
||||
commonPath := commonPath(c.path) |
||||
now := timeNow() |
||||
|
||||
filepath.Walk(commonPath, func(path string, info fs.FileInfo, err error) error { //nolint:errcheck
|
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if !info.IsDir() { |
||||
params := decodeRecordPath(c.path, path) |
||||
if params != nil { |
||||
if now.Sub(params.time) > c.deleteAfter { |
||||
c.Log(logger.Debug, "removing %s", path) |
||||
os.Remove(path) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
filepath.Walk(commonPath, func(path string, info fs.FileInfo, err error) error { //nolint:errcheck
|
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if info.IsDir() { |
||||
os.Remove(path) |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestCleaner(t *testing.T) { |
||||
timeNow = func() time.Time { |
||||
return time.Date(2009, 0o5, 20, 22, 15, 25, 427000, time.UTC) |
||||
} |
||||
|
||||
dir, err := os.MkdirTemp("", "mediamtx-cleaner") |
||||
require.NoError(t, err) |
||||
defer os.RemoveAll(dir) |
||||
|
||||
recordPath := filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f") |
||||
|
||||
err = os.Mkdir(filepath.Join(dir, "mypath"), 0o755) |
||||
require.NoError(t, err) |
||||
|
||||
err = os.WriteFile(filepath.Join(dir, "mypath", "2008-05-20_22-15-25-000125.mp4"), []byte{1}, 0o644) |
||||
require.NoError(t, err) |
||||
|
||||
err = os.WriteFile(filepath.Join(dir, "mypath", "2009-05-20_22-15-25-000427.mp4"), []byte{1}, 0o644) |
||||
require.NoError(t, err) |
||||
|
||||
c := NewCleaner( |
||||
recordPath, |
||||
10*time.Second, |
||||
nilLogger{}, |
||||
) |
||||
defer c.Close() |
||||
|
||||
time.Sleep(500 * time.Millisecond) |
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "mypath", "2008-05-20_22-15-25-000125.mp4")) |
||||
require.Error(t, err) |
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "mypath", "2009-05-20_22-15-25-000427.mp4")) |
||||
require.NoError(t, err) |
||||
} |
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"io" |
||||
"time" |
||||
|
||||
"github.com/aler9/writerseeker" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
) |
||||
|
||||
func writePart(f io.Writer, partTracks map[*track]*fmp4.PartTrack) error { |
||||
fmp4PartTracks := make([]*fmp4.PartTrack, len(partTracks)) |
||||
i := 0 |
||||
for _, partTrack := range partTracks { |
||||
fmp4PartTracks[i] = partTrack |
||||
i++ |
||||
} |
||||
|
||||
part := &fmp4.Part{ |
||||
Tracks: fmp4PartTracks, |
||||
} |
||||
|
||||
var ws writerseeker.WriterSeeker |
||||
err := part.Marshal(&ws) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
_, err = f.Write(ws.Bytes()) |
||||
return err |
||||
} |
||||
|
||||
type part struct { |
||||
s *segment |
||||
startDTS time.Duration |
||||
|
||||
partTracks map[*track]*fmp4.PartTrack |
||||
endDTS time.Duration |
||||
} |
||||
|
||||
func newPart( |
||||
s *segment, |
||||
startDTS time.Duration, |
||||
) *part { |
||||
return &part{ |
||||
s: s, |
||||
startDTS: startDTS, |
||||
partTracks: make(map[*track]*fmp4.PartTrack), |
||||
} |
||||
} |
||||
|
||||
func (p *part) close() error { |
||||
return writePart(p.s.f, p.partTracks) |
||||
} |
||||
|
||||
func (p *part) record(track *track, sample *sample) error { |
||||
partTrack, ok := p.partTracks[track] |
||||
if !ok { |
||||
partTrack = &fmp4.PartTrack{ |
||||
ID: track.initTrack.ID, |
||||
BaseTime: durationGoToMp4(sample.dts-p.s.startDTS, track.initTrack.TimeScale), |
||||
} |
||||
p.partTracks[track] = partTrack |
||||
} |
||||
|
||||
partTrack.Samples = append(partTrack.Samples, sample.PartSample) |
||||
p.endDTS = sample.dts |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (p *part) duration() time.Duration { |
||||
return p.endDTS - p.startDTS |
||||
} |
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
// Package record contains the recording system.
|
||||
package record |
@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"regexp" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
) |
||||
|
||||
func leadingZeros(v int, size int) string { |
||||
out := strconv.FormatInt(int64(v), 10) |
||||
if len(out) >= size { |
||||
return out |
||||
} |
||||
|
||||
out2 := "" |
||||
for i := 0; i < (size - len(out)); i++ { |
||||
out2 += "0" |
||||
} |
||||
|
||||
return out2 + out |
||||
} |
||||
|
||||
type recordPathParams struct { |
||||
path string |
||||
time time.Time |
||||
} |
||||
|
||||
func decodeRecordPath(format string, v string) *recordPathParams { |
||||
re := format |
||||
re = strings.ReplaceAll(re, "\\", "\\\\") |
||||
re = strings.ReplaceAll(re, "%path", "(.*?)") |
||||
re = strings.ReplaceAll(re, "%Y", "([0-9]{4})") |
||||
re = strings.ReplaceAll(re, "%m", "([0-9]{2})") |
||||
re = strings.ReplaceAll(re, "%d", "([0-9]{2})") |
||||
re = strings.ReplaceAll(re, "%H", "([0-9]{2})") |
||||
re = strings.ReplaceAll(re, "%M", "([0-9]{2})") |
||||
re = strings.ReplaceAll(re, "%S", "([0-9]{2})") |
||||
re = strings.ReplaceAll(re, "%f", "([0-9]{6})") |
||||
r := regexp.MustCompile(re) |
||||
|
||||
var groupMapping []string |
||||
cur := format |
||||
for { |
||||
i := strings.Index(cur, "%") |
||||
if i < 0 { |
||||
break |
||||
} |
||||
|
||||
cur = cur[i:] |
||||
|
||||
for _, va := range []string{ |
||||
"%path", |
||||
"%Y", |
||||
"%m", |
||||
"%d", |
||||
"%H", |
||||
"%M", |
||||
"%S", |
||||
"%f", |
||||
} { |
||||
if strings.HasPrefix(cur, va) { |
||||
groupMapping = append(groupMapping, va) |
||||
} |
||||
} |
||||
|
||||
cur = cur[1:] |
||||
} |
||||
|
||||
matches := r.FindStringSubmatch(v) |
||||
if matches == nil { |
||||
return nil |
||||
} |
||||
|
||||
values := make(map[string]string) |
||||
|
||||
for i, match := range matches[1:] { |
||||
values[groupMapping[i]] = match |
||||
} |
||||
|
||||
var year int |
||||
var month time.Month = 1 |
||||
day := 1 |
||||
var hour int |
||||
var minute int |
||||
var second int |
||||
var micros int |
||||
|
||||
for k, v := range values { |
||||
switch k { |
||||
case "%Y": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
year = int(tmp) |
||||
|
||||
case "%m": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
month = time.Month(int(tmp)) |
||||
|
||||
case "%d": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
day = int(tmp) |
||||
|
||||
case "%H": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
hour = int(tmp) |
||||
|
||||
case "%M": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
minute = int(tmp) |
||||
|
||||
case "%S": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
second = int(tmp) |
||||
|
||||
case "%f": |
||||
tmp, _ := strconv.ParseInt(v, 10, 64) |
||||
micros = int(tmp) |
||||
} |
||||
} |
||||
|
||||
t := time.Date(year, month, day, hour, minute, second, micros*1000, time.Local) |
||||
|
||||
return &recordPathParams{ |
||||
path: values["%path"], |
||||
time: t, |
||||
} |
||||
} |
||||
|
||||
func encodeRecordPath(params *recordPathParams, v string) string { |
||||
v = strings.ReplaceAll(v, "%Y", strconv.FormatInt(int64(params.time.Year()), 10)) |
||||
v = strings.ReplaceAll(v, "%m", leadingZeros(int(params.time.Month()), 2)) |
||||
v = strings.ReplaceAll(v, "%d", leadingZeros(params.time.Day(), 2)) |
||||
v = strings.ReplaceAll(v, "%H", leadingZeros(params.time.Hour(), 2)) |
||||
v = strings.ReplaceAll(v, "%M", leadingZeros(params.time.Minute(), 2)) |
||||
v = strings.ReplaceAll(v, "%S", leadingZeros(params.time.Second(), 2)) |
||||
v = strings.ReplaceAll(v, "%f", leadingZeros(params.time.Nanosecond()/1000, 6)) |
||||
return v |
||||
} |
@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"io" |
||||
"os" |
||||
"path/filepath" |
||||
"time" |
||||
|
||||
"github.com/aler9/writerseeker" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
|
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
) |
||||
|
||||
var timeNow = time.Now |
||||
|
||||
func writeInit(f io.Writer, tracks []*track) error { |
||||
fmp4Tracks := make([]*fmp4.InitTrack, len(tracks)) |
||||
for i, track := range tracks { |
||||
fmp4Tracks[i] = track.initTrack |
||||
} |
||||
|
||||
init := fmp4.Init{ |
||||
Tracks: fmp4Tracks, |
||||
} |
||||
|
||||
var ws writerseeker.WriterSeeker |
||||
err := init.Marshal(&ws) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
_, err = f.Write(ws.Bytes()) |
||||
return err |
||||
} |
||||
|
||||
type segment struct { |
||||
r *Agent |
||||
startDTS time.Duration |
||||
|
||||
fpath string |
||||
f *os.File |
||||
curPart *part |
||||
} |
||||
|
||||
func newSegment( |
||||
r *Agent, |
||||
startDTS time.Duration, |
||||
) *segment { |
||||
return &segment{ |
||||
r: r, |
||||
startDTS: startDTS, |
||||
} |
||||
} |
||||
|
||||
func (s *segment) close() error { |
||||
if s.curPart != nil { |
||||
err := s.flush() |
||||
|
||||
if s.f != nil { |
||||
s.r.Log(logger.Debug, "closing segment %s", s.fpath) |
||||
|
||||
err2 := s.f.Close() |
||||
if err == nil { |
||||
err = err2 |
||||
} |
||||
} |
||||
|
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (s *segment) record(track *track, sample *sample) error { |
||||
if s.curPart == nil { |
||||
s.curPart = newPart(s, sample.dts) |
||||
} else if s.curPart.duration() >= s.r.partDuration { |
||||
err := s.flush() |
||||
if err != nil { |
||||
s.curPart = nil |
||||
return err |
||||
} |
||||
|
||||
s.curPart = newPart(s, sample.dts) |
||||
} |
||||
|
||||
return s.curPart.record(track, sample) |
||||
} |
||||
|
||||
func (s *segment) flush() error { |
||||
if s.f == nil { |
||||
s.fpath = encodeRecordPath(&recordPathParams{time: timeNow()}, s.r.path) |
||||
s.r.Log(logger.Debug, "opening segment %s", s.fpath) |
||||
|
||||
err := os.MkdirAll(filepath.Dir(s.fpath), 0o755) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
f, err := os.Create(s.fpath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
err = writeInit(f, s.r.tracks) |
||||
if err != nil { |
||||
f.Close() |
||||
return err |
||||
} |
||||
|
||||
s.f = f |
||||
} |
||||
|
||||
return s.curPart.close() |
||||
} |
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
package record |
||||
|
||||
import ( |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
) |
||||
|
||||
type track struct { |
||||
r *Agent |
||||
initTrack *fmp4.InitTrack |
||||
|
||||
nextSample *sample |
||||
} |
||||
|
||||
func newTrack( |
||||
r *Agent, |
||||
initTrack *fmp4.InitTrack, |
||||
) *track { |
||||
return &track{ |
||||
r: r, |
||||
initTrack: initTrack, |
||||
} |
||||
} |
||||
|
||||
func (t *track) record(sample *sample) error { |
||||
// wait the first video sample before setting hasVideo
|
||||
if t.initTrack.Codec.IsVideo() { |
||||
t.r.hasVideo = true |
||||
} |
||||
|
||||
if t.r.currentSegment == nil { |
||||
t.r.currentSegment = newSegment(t.r, sample.dts) |
||||
} |
||||
|
||||
sample, t.nextSample = t.nextSample, sample |
||||
if sample == nil { |
||||
return nil |
||||
} |
||||
sample.Duration = uint32(durationGoToMp4(t.nextSample.dts-sample.dts, t.initTrack.TimeScale)) |
||||
|
||||
err := t.r.currentSegment.record(t, sample) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if (!t.r.hasVideo || t.initTrack.Codec.IsVideo()) && |
||||
!t.nextSample.IsNonSyncSample && |
||||
(t.nextSample.dts-t.r.currentSegment.startDTS) >= t.r.segmentDuration { |
||||
err := t.r.currentSegment.close() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
t.r.currentSegment = newSegment(t.r, t.nextSample.dts) |
||||
} |
||||
|
||||
return nil |
||||
} |
Loading…
Reference in new issue