diff --git a/api/openapi.yaml b/api/openapi.yaml index 18c46f1b..f057596f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -592,6 +592,14 @@ components: type: array items: type: string + resolutions: + type: array + items: + type: string + bitrates: + type: array + items: + type: string bytesReceived: type: integer format: int64 diff --git a/internal/api/api_paths_test.go b/internal/api/api_paths_test.go index 4576a9c8..0f72a4bc 100644 --- a/internal/api/api_paths_test.go +++ b/internal/api/api_paths_test.go @@ -42,6 +42,8 @@ func TestPathsList(t *testing.T) { Ready: true, ReadyTime: &now, Tracks: []string{"H264", "Opus"}, + Resolutions: []string{"1920x1080", ""}, + Bitrates: []string{"4500kbps", ""}, BytesReceived: 1000, BytesSent: 2000, Readers: []defs.APIPathSourceOrReader{ diff --git a/internal/codecprocessor/h264.go b/internal/codecprocessor/h264.go index 98350f98..18baab9b 100644 --- a/internal/codecprocessor/h264.go +++ b/internal/codecprocessor/h264.go @@ -309,3 +309,12 @@ func (t *h264) ProcessRTPPacket( //nolint:dupl return nil } + +// ExtractH264Resolution extracts width and height from H264 SPS +func ExtractH264Resolution(sps []byte) (int, int) { + var s mch264.SPS + if err := s.Unmarshal(sps); err != nil { + return 0, 0 + } + return s.Width(), s.Height() +} diff --git a/internal/codecprocessor/h265.go b/internal/codecprocessor/h265.go index 7583ba8d..05a3e896 100644 --- a/internal/codecprocessor/h265.go +++ b/internal/codecprocessor/h265.go @@ -341,3 +341,12 @@ func (t *h265) ProcessRTPPacket( //nolint:dupl return nil } + +// ExtractH265Resolution extracts width and height from H265 SPS +func ExtractH265Resolution(sps []byte) (int, int) { + var s mch265.SPS + if err := s.Unmarshal(sps); err != nil { + return 0, 0 + } + return s.Width(), s.Height() +} diff --git a/internal/codecprocessor/mpeg1_video.go b/internal/codecprocessor/mpeg1_video.go index 8923c379..4127fafd 100644 --- a/internal/codecprocessor/mpeg1_video.go +++ b/internal/codecprocessor/mpeg1_video.go @@ -108,3 +108,28 @@ func (t *mpeg1Video) ProcessRTPPacket( //nolint:dupl return nil } + + +// ExtractMPEG1Resolution extracts width and height from MPEG-1/2 Video config +func ExtractMPEG1Resolution(config []byte) (int, int) { + // MPEG-1/2 Video sequence header parsing for resolution + // Look for sequence header start code 0x00 0x00 0x01 0xB3 + if len(config) < 12 { + return 0, 0 + } + for i := 0; i < len(config)-4; i++ { + if config[i] == 0x00 && config[i+1] == 0x00 && config[i+2] == 0x01 && config[i+3] == 0xB3 { + // Sequence header starts after start code + data := config[i+4:] + if len(data) < 8 { + continue + } + // horizontal_size_value: 12 bits + width := (uint32(data[0]) << 4) | (uint32(data[1]) >> 4) + // vertical_size_value: 12 bits + height := ((uint32(data[1]) & 0x0F) << 8) | uint32(data[2]) + return int(width), int(height) + } + } + return 0, 0 +} diff --git a/internal/codecprocessor/mpeg4_video.go b/internal/codecprocessor/mpeg4_video.go index 4cf7a9fc..cce26055 100644 --- a/internal/codecprocessor/mpeg4_video.go +++ b/internal/codecprocessor/mpeg4_video.go @@ -157,3 +157,119 @@ func (t *mpeg4Video) ProcessRTPPacket( //nolint:dupl return nil } + + +// ExtractMPEG4Resolution extracts width and height from MPEG-4 Video config +func ExtractMPEG4Resolution(config []byte) (int, int) { + // MPEG-4 Video Object Layer (VOL) parsing for resolution + // Look for VOL start code 0x00 0x00 0x01 0x20 + if len(config) < 20 { + return 0, 0 + } + for i := 0; i < len(config)-4; i++ { + if config[i] == 0x00 && config[i+1] == 0x00 && config[i+2] == 0x01 && config[i+3] == 0x20 { + // VOL header starts after start code + data := config[i+4:] + if len(data) < 10 { + continue + } + // Skip vol_id (4 bits), random_accessible_vol (1), video_object_type_indication (8) + // is_object_layer_identifier (1) + bitPos := 0 + // vol_id: 4 bits + bitPos += 4 + // random_accessible_vol: 1 bit + bitPos += 1 + // video_object_type_indication: 8 bits + bitPos += 8 + // is_object_layer_identifier: 1 bit + isObjectLayer := getBit(data, bitPos) + bitPos += 1 + if isObjectLayer { + // video_object_layer_verid: 4 bits + bitPos += 4 + // video_object_layer_priority: 3 bits + bitPos += 3 + } + // aspect_ratio_info: 4 bits + aspectRatio := getBits(data, bitPos, 4) + bitPos += 4 + if aspectRatio == 15 { // extended_PAR + // par_width: 8 bits + bitPos += 8 + // par_height: 8 bits + bitPos += 8 + } + // vol_control_parameters: 1 bit + volControl := getBit(data, bitPos) + bitPos += 1 + if volControl { + // chroma_format: 2 bits + bitPos += 2 + // low_delay: 1 bit + bitPos += 1 + // vbv_parameters: 1 bit + vbv := getBit(data, bitPos) + bitPos += 1 + if vbv { + // bit_rate: 15 bits + bitPos += 15 + // buffer_size: 15 bits + bitPos += 15 + // vbv_occupancy: 15 bits + bitPos += 15 + } + } + // video_object_layer_shape: 2 bits + shape := getBits(data, bitPos, 2) + bitPos += 2 + if shape == 3 { // gray_scale + // video_object_layer_shape_extension: 4 bits + bitPos += 4 + } + // marker_bit: 1 bit + bitPos += 1 + // vop_time_increment_resolution: 16 bits + bitPos += 16 + // marker_bit: 1 bit + bitPos += 1 + // fixed_vop_rate: 1 bit + fixedVop := getBit(data, bitPos) + bitPos += 1 + if fixedVop { + // fixed_vop_time_increment: variable bits + // skip for now + } + // marker_bit: 1 bit + bitPos += 1 + // video_object_layer_width: 13 bits + width := getBits(data, bitPos, 13) + bitPos += 13 + // marker_bit: 1 bit + bitPos += 1 + // video_object_layer_height: 13 bits + height := getBits(data, bitPos, 13) + return int(width), int(height) + } + } + return 0, 0 +} + +func getBit(data []byte, bitPos int) bool { + byteIndex := bitPos / 8 + bitIndex := 7 - (bitPos % 8) + if byteIndex >= len(data) { + return false + } + return (data[byteIndex] & (1 << bitIndex)) != 0 +} + +func getBits(data []byte, bitPos int, numBits int) uint32 { + var val uint32 + for i := 0; i < numBits; i++ { + if getBit(data, bitPos+i) { + val |= 1 << (numBits - 1 - i) + } + } + return val +} \ No newline at end of file diff --git a/internal/core/path.go b/internal/core/path.go index a06973a8..fb91c5e8 100644 --- a/internal/core/path.go +++ b/internal/core/path.go @@ -571,6 +571,18 @@ func (pa *path) doAPIPathsGet(req pathAPIPathsGetReq) { } return defs.MediasToCodecs(pa.stream.Desc.Medias) }(), + Resolutions: func() []string { + if !pa.isReady() { + return []string{} + } + return defs.MediasToResolutions(pa.stream.Desc.Medias) + }(), + Bitrates: func() []string { + if !pa.isReady() { + return []string{} + } + return pa.bitrates() + }(), BytesReceived: func() uint64 { if !pa.isReady() { return 0 @@ -951,3 +963,17 @@ func (pa *path) APIPathsGet(req pathAPIPathsGetReq) (*defs.APIPath, error) { return nil, fmt.Errorf("terminated") } } + +func (pa *path) bitrates() []string { + elapsed := time.Since(pa.readyTime).Seconds() + if elapsed > 0 { + totalBps := float64(pa.stream.BytesReceived()) * 8 / elapsed + bitrateStr := fmt.Sprintf("%.0f kbps", totalBps/1000) + bitrates := make([]string, len(pa.stream.Desc.Medias)) + for i := range bitrates { + bitrates[i] = bitrateStr + } + return bitrates + } + return []string{} +} diff --git a/internal/defs/api.go b/internal/defs/api.go index 9bc30831..13042041 100644 --- a/internal/defs/api.go +++ b/internal/defs/api.go @@ -88,6 +88,8 @@ type APIPath struct { Ready bool `json:"ready"` ReadyTime *time.Time `json:"readyTime"` Tracks []string `json:"tracks"` + Resolutions []string `json:"resolutions"` + Bitrates []string `json:"bitrates"` BytesReceived uint64 `json:"bytesReceived"` BytesSent uint64 `json:"bytesSent"` Readers []APIPathSourceOrReader `json:"readers"` diff --git a/internal/defs/source.go b/internal/defs/source.go index ae5fd893..fc51d033 100644 --- a/internal/defs/source.go +++ b/internal/defs/source.go @@ -8,6 +8,7 @@ import ( "github.com/bluenviron/gortsplib/v5/pkg/format" "github.com/bluenviron/mediamtx/internal/logger" + "github.com/bluenviron/mediamtx/internal/codecprocessor" ) // Source is an entity that can provide a stream. @@ -52,6 +53,42 @@ func MediasToCodecs(medias []*description.Media) []string { return FormatsToCodecs(formats) } +// MediasToResolutions returns the resolutions of given medias. +func MediasToResolutions(medias []*description.Media) []string { + var ret []string + for _, media := range medias { + for _, forma := range media.Formats { + ret = append(ret, getResolution(forma)) + } + } + return ret +} + +func getResolution(forma format.Format) string { + if h264f, ok := forma.(*format.H264); ok { + width, height := codecprocessor.ExtractH264Resolution(h264f.SPS) + if width > 0 && width <= 10000 && height > 0 && height <= 10000 { + return fmt.Sprintf("%dx%d", width, height) + } + } else if h265f, ok := forma.(*format.H265); ok { + width, height := codecprocessor.ExtractH265Resolution(h265f.SPS) + if width > 0 && width <= 10000 && height > 0 && height <= 10000 { + return fmt.Sprintf("%dx%d", width, height) + } + } else if mpeg4f, ok := forma.(*format.MPEG4Video); ok { + width, height := codecprocessor.ExtractMPEG4Resolution(mpeg4f.Config) + if width > 0 && width <= 10000 && height > 0 && height <= 10000 { + return fmt.Sprintf("%dx%d", width, height) + } + } else if _, ok := forma.(*format.MPEG1Video); ok { + width, height := codecprocessor.ExtractMPEG1Resolution(codecprocessor.MPEG1VideoDefaultConfig) + if width > 0 && width <= 10000 && height > 0 && height <= 10000 { + return fmt.Sprintf("%dx%d", width, height) + } + } + return "" +} + // MediasInfo returns a description of medias. func MediasInfo(medias []*description.Media) string { var formats []format.Format