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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
// Package record contains the recording system.
|
||||||
|
package record |
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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