feat: add resolution and bitrate information to API responses

This commit is contained in:
jeanleao 2026-01-09 20:01:16 -03:00
parent 6acf9f1d2b
commit 79ee4c3db5
9 changed files with 234 additions and 0 deletions

View file

@ -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

View file

@ -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{

View file

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

View file

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

View file

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

View file

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

View file

@ -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{}
}

View file

@ -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"`

View file

@ -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