diff --git a/README.md b/README.md index 72bc6f6..82fe4bb 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,14 @@ RTCP 피드백은 유지한다. ICE restart에서도 같은 영상 방향을 유 입력 트랙과 처리 프레임의 존재를 반환 sender 유무와 구분해야 한다. 이 기능은 별도 `ai_processing: on_device` 직접 RTMP 세션과 다른 WebRTC 방향 선택이다. -H.264 WebRTC 반환 인코더는 목표/상한 2Mbps, VBV 버퍼 200kbit로 제한해 -상한 없는 CRF 출력의 큰 키프레임 버스트를 줄인다. VP8의 기존 2Mbps 설정과 -플랫폼 RTMP egress의 `EGRESS_VIDEO_BITRATE` 정책은 유지한다. 반환 상한이 +WebRTC 반환 인코더의 목표 bitrate는 처리 프레임 해상도를 따른다. 720p 이하는 +2Mbps, FHD 세션(1080×1920)은 4Mbps이며 VP8과 H.264가 같다. H.264는 상한을 목표와 +같게 두고 VBV 버퍼를 1초 분량(2Mbit/4Mbit)으로 제한해 상한 없는 CRF 출력의 큰 +키프레임 버스트를 막는다. 한 프레임은 버퍼 크기(720p 250KB, FHD 500KB)를 넘지 +않고, 실측 키프레임은 720p 약 29KB, FHD 약 37KB다. 0.1초 버퍼는 키프레임 화질을 +크게 떨어뜨려 쓰지 않는다. H.264 level은 720p 3.1, FHD 4.0이다. VP8은 +`-cpu-used 8`을 유지한다. 플랫폼 RTMP egress의 `EGRESS_VIDEO_BITRATE` 정책은 +유지한다. 반환 상한이 네트워크 손실이나 전체 end-to-end 지연을 보장하는 것은 아니며, 운영 계측에서 피드백 기반 품질 적응과 패킷 pacing을 별도로 검증해야 한다. diff --git a/internal/media/ffmpeg.go b/internal/media/ffmpeg.go index 993718a..1fc366e 100644 --- a/internal/media/ffmpeg.go +++ b/internal/media/ffmpeg.go @@ -437,6 +437,25 @@ func (t *FFmpegTranscoder) startDecoder(ctx context.Context, width, height uint1 return process, nil } +const ( + returnVideoBitrate = "2M" + returnVideoBitrateFHD = "4M" +) + +// returnVideoBitrateFor는 WebRTC 반환 인코더(VP8·H.264 공통)의 목표 bitrate다. +// 720p 이하는 종전 2M을 유지하고, FHD 세션(디코더 장변 1920)은 같은 화소당 +// 비트를 맞추도록 4M을 쓴다. 실측(#478)에서 4M은 FHD PSNR을 약 1~2.3dB 올렸고 +// 세션당 CPU는 측정 오차 안에 머물렀다. +func returnVideoBitrateFor(width, height uint16) string { + if int(width)*int(height) > 1280*720 { + return returnVideoBitrateFHD + } + return returnVideoBitrate +} + +// startEncoder의 cpu-used는 8을 유지한다. VP8 realtime 모드의 양수 cpu-used는 +// 고정 속도가 아니라 프레임 간격 대비 CPU 예산((16-n)/16)이라, 5~6으로 낮춰도 +// 실측(#478, 30fps 페이싱·스레드 1)에서 화질 차이가 없이 CPU만 최대 30% 늘었다. func (t *FFmpegTranscoder) startEncoder(ctx context.Context, width, height uint16) (*ffmpegProcess, error) { arguments := []string{ "-hide_banner", "-loglevel", "error", @@ -456,7 +475,7 @@ func (t *FFmpegTranscoder) startEncoder(ctx context.Context, width, height uint1 } arguments = append(arguments, "-an", "-c:v", "libvpx", "-deadline", "realtime", "-cpu-used", "8", - "-lag-in-frames", "0", "-auto-alt-ref", "0", "-g", "30", "-b:v", "2M", + "-lag-in-frames", "0", "-auto-alt-ref", "0", "-g", "30", "-b:v", returnVideoBitrateFor(width, height), "-pix_fmt", "yuv420p", "-flush_packets", "1", "-f", "ivf", "-blocksize", "1024", "pipe:1", ) process, err := t.startFFmpeg(ctx, "encoder", nil, nil, arguments...) diff --git a/internal/media/h264.go b/internal/media/h264.go index ea38acf..a6e3441 100644 --- a/internal/media/h264.go +++ b/internal/media/h264.go @@ -288,8 +288,24 @@ func (t *FFmpegTranscoder) startH264Decoder(ctx context.Context, filter string) return process, nil } +// h264ReturnLevelFor는 반환 해상도를 담는 가장 낮은 baseline level이다. +// 720×1280(3600 MB)은 3.1, 1080×1920(8160 MB, 30fps 244800 MB/s)은 4.0이 필요하다. +// level이 프레임 크기보다 작으면 하드웨어 디코더가 스트림을 거부하거나 버퍼를 +// 잘못 잡을 수 있다. +func h264ReturnLevelFor(width, height uint16) string { + if int(width)*int(height) > 1280*720 { + return "4.0" + } + return "3.1" +} + // startH264Encoder도 VP8 경로의 startEncoder와 인자를 맞춘다(#130). // 스레드는 VP8과 마찬가지로 자원 거버너(EncoderThreads)를 따른다. +// +// maxrate는 목표 bitrate와 같게 두어 상한 없는 출력의 키프레임 버스트를 막고(#461), +// VBV 버퍼는 1초 분량으로 둔다. 0.1초(200k) 버퍼는 키프레임을 P 프레임 크기로 +// 눌러 1초마다 화질이 꺼졌다. 실측(#478)에서 1초 버퍼의 FHD 최저 프레임 PSNR은 +// 약 2dB 올랐고 키프레임은 22KB에서 37KB로만 커졌다. func (t *FFmpegTranscoder) startH264Encoder(ctx context.Context, width, height uint16) (*ffmpegProcess, error) { arguments := []string{ "-hide_banner", "-loglevel", "error", @@ -303,9 +319,10 @@ func (t *FFmpegTranscoder) startH264Encoder(ctx context.Context, width, height u if t.options.EncoderThreads > 0 { arguments = append(arguments, "-threads", strconv.Itoa(t.options.EncoderThreads)) } + bitrate := returnVideoBitrateFor(width, height) arguments = append(arguments, - "-an", "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-level:v", "3.1", - "-b:v", "2M", "-maxrate", "2M", "-bufsize", "200k", + "-an", "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-level:v", h264ReturnLevelFor(width, height), + "-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bitrate, "-pix_fmt", "yuv420p", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", "-bf", "0", "-x264-params", "repeat-headers=1:aud=1:annexb=1", "-f", "h264", "-flush_packets", "1", "pipe:1", ) diff --git a/internal/media/h264_args_test.go b/internal/media/h264_args_test.go index 370e79d..6a46f45 100644 --- a/internal/media/h264_args_test.go +++ b/internal/media/h264_args_test.go @@ -2,6 +2,7 @@ package media import ( "context" + "fmt" "os/exec" "strings" "testing" @@ -119,3 +120,49 @@ func TestH264EncoderUsesLowLatencyArgs(t *testing.T) { assertArgPair(t, captured, "-threads", map[int]string{1: "1", 4: "4"}[threads]) } } + +// TestReturnEncodersScaleWithResolution은 반환 인코더의 bitrate·VBV·level이 +// 처리 프레임 해상도를 따르는지 고정한다(#478). 720p는 종전 2M을 유지하고 FHD +// 세션은 4M을 쓴다. H.264 VBV는 1초 분량이고, level은 1080×1920을 담아야 한다. +func TestReturnEncodersScaleWithResolution(t *testing.T) { + cases := []struct { + width, height uint16 + bitrate string + level string + }{ + {720, 1280, "2M", "3.1"}, + {1280, 720, "2M", "3.1"}, + {1080, 1920, "4M", "4.0"}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) { + h264 := captureFFmpegArgs(t, + TranscoderOptions{VideoCodec: VideoCodecH264, WireFormat: config.WireFormatRaw, EncoderThreads: 1}, + func(transcoder *FFmpegTranscoder) error { + process, err := transcoder.startH264Encoder(context.Background(), tc.width, tc.height) + if err == nil { + process.close() + } + return err + }) + assertArgPair(t, h264, "-level:v", tc.level) + assertArgPair(t, h264, "-b:v", tc.bitrate) + assertArgPair(t, h264, "-maxrate", tc.bitrate) + assertArgPair(t, h264, "-bufsize", tc.bitrate) + + vp8 := captureFFmpegArgs(t, + TranscoderOptions{VideoCodec: VideoCodecVP8, WireFormat: config.WireFormatRaw, EncoderThreads: 1}, + func(transcoder *FFmpegTranscoder) error { + process, err := transcoder.startEncoder(context.Background(), tc.width, tc.height) + if err == nil { + process.close() + } + return err + }) + assertArgPair(t, vp8, "-c:v", "libvpx") + assertArgPair(t, vp8, "-b:v", tc.bitrate) + // realtime 모드의 cpu-used는 CPU 예산이라 낮추면 세션당 CPU만 는다. + assertArgPair(t, vp8, "-cpu-used", "8") + }) + } +} diff --git a/internal/media/h264_return_rate_test.go b/internal/media/h264_return_rate_test.go index 26e7039..9ccf03f 100644 --- a/internal/media/h264_return_rate_test.go +++ b/internal/media/h264_return_rate_test.go @@ -2,6 +2,7 @@ package media import ( "context" + "fmt" "inno-live-server/internal/config" "math/rand" "os/exec" @@ -9,50 +10,66 @@ import ( "time" ) +// TestH264ReturnRateBoundsHighDetailFrames는 상한 없는 출력의 키프레임 버스트를 +// 막는 VBV(#461)가 1초 버퍼(#478)에서도 지켜지는지 최악 입력(무작위 화소)으로 +// 확인한다. 한 프레임은 버퍼(bitrate×1초)를, 1초 합계는 maxrate+버퍼를 넘을 수 없다. func TestH264ReturnRateBoundsHighDetailFrames(t *testing.T) { path, err := exec.LookPath("ffmpeg") if err != nil { t.Skip("ffmpeg required") } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - transcoder := NewFFmpegTranscoder(path, testLogger(), nil, TranscoderOptions{VideoCodec: VideoCodecH264, WireFormat: config.WireFormatRaw, EncoderThreads: 1}) - input := make(chan frame, 4) - output := make(chan frame, 4) - done := make(chan error, 1) - go func() { done <- transcoder.EncodeStream(ctx, input, output); close(output) }() - go func() { - defer close(input) - rng := rand.New(rand.NewSource(461)) - for i := 0; i < 30; i++ { - data := make([]byte, rawFrameSize(1280, 720)) - rng.Read(data[:1280*720]) - for k := 1280 * 720; k < len(data); k++ { - data[k] = 128 + for _, tc := range []struct { + width, height uint16 + bitsPerSecond int + }{ + {1280, 720, 2_000_000}, + {1080, 1920, 4_000_000}, + } { + t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + transcoder := NewFFmpegTranscoder(path, testLogger(), nil, TranscoderOptions{VideoCodec: VideoCodecH264, WireFormat: config.WireFormatRaw, EncoderThreads: 1}) + input := make(chan frame, 4) + output := make(chan frame, 4) + done := make(chan error, 1) + go func() { done <- transcoder.EncodeStream(ctx, input, output); close(output) }() + go func() { + defer close(input) + rng := rand.New(rand.NewSource(461)) + luma := int(tc.width) * int(tc.height) + for i := 0; i < 30; i++ { + data := make([]byte, rawFrameSize(tc.width, tc.height)) + rng.Read(data[:luma]) + for k := luma; k < len(data); k++ { + data[k] = 128 + } + select { + case input <- frame{data: data, width: tc.width, height: tc.height, timestamp: uint32(i * 3000)}: + case <-ctx.Done(): + return + } + } + }() + count, total, largest := 0, 0, 0 + for item := range output { + count++ + total += len(item.data) + if len(item.data) > largest { + largest = len(item.data) + } } - select { - case input <- frame{data: data, width: 1280, height: 720, timestamp: uint32(i * 3000)}: - case <-ctx.Done(): - return + if err := <-done; err != nil { + t.Fatal(err) } - } - }() - count, total, largest := 0, 0, 0 - for item := range output { - count++ - total += len(item.data) - if len(item.data) > largest { - largest = len(item.data) - } - } - if err := <-done; err != nil { - t.Fatal(err) - } - if count != 30 { - t.Fatalf("encoded frames=%d, want 30", count) - } - if largest > 50000 || total > 312500 { - t.Fatalf("return stream exceeds bounded preview budget: largest=%d bytes, one-second total=%d bytes", largest, total) + if count != 30 { + t.Fatalf("encoded frames=%d, want 30", count) + } + // 액세스 유닛에는 SPS/PPS·AUD 헤더가 붙으므로 약간의 여유를 둔다. + buffer := tc.bitsPerSecond / 8 + if largest > buffer+1024 || total > 2*buffer+1024 { + t.Fatalf("return stream exceeds VBV budget: largest=%d bytes (buffer %d), one-second total=%d bytes (limit %d)", largest, buffer, total, 2*buffer) + } + t.Logf("30 high-detail frames: largest=%d bytes, total=%d bytes", largest, total) + }) } - t.Logf("30 high-detail frames: largest=%d bytes, total=%d bytes", largest, total) } diff --git a/internal/media/return_encoder_harness_test.go b/internal/media/return_encoder_harness_test.go new file mode 100644 index 0000000..df71ee5 --- /dev/null +++ b/internal/media/return_encoder_harness_test.go @@ -0,0 +1,111 @@ +//go:build egress_harness + +// 반환 인코더 해상도별 설정(#478)의 실 ffmpeg 검증. 720p·FHD 처리 프레임을 +// VP8·H.264 반환 인코더에 넣고 출력 해상도·H.264 level·bitrate를 확인한다. +// +// go test -tags egress_harness -run TestReturnEncoderResolutionProfile ./internal/media -v +package media + +import ( + "context" + "fmt" + "os/exec" + "testing" + "time" + + "inno-live-server/internal/config" + "inno-live-server/internal/metrics" +) + +// patternFrames는 움직이는 테스트 패턴(testsrc2)을 raw yuv420p 프레임으로 만든다. +// 0으로 채운 프레임은 bitrate를 거의 쓰지 않아 rate control을 검증할 수 없다. +func patternFrames(t *testing.T, count int, width, height uint16) []frame { + t.Helper() + output, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", fmt.Sprintf("testsrc2=s=%dx%d:r=30,noise=alls=6:allf=t", width, height), + "-frames:v", fmt.Sprint(count), "-pix_fmt", "yuv420p", "-f", "rawvideo", "pipe:1").Output() + if err != nil { + t.Fatalf("generate pattern frames: %v", err) + } + size := rawFrameSize(width, height) + if len(output) != size*count { + t.Fatalf("pattern bytes=%d, want %d", len(output), size*count) + } + frames := make([]frame, count) + for i := range frames { + frames[i] = frame{data: output[i*size : (i+1)*size], width: width, height: height, timestamp: uint32(i * 3000)} + } + return frames +} + +func TestReturnEncoderResolutionProfile(t *testing.T) { + requireTool(t, "ffmpeg") + const seconds = 3 + for _, tc := range []struct { + width, height uint16 + bitsPerSecond int + levelIDC byte + }{ + {720, 1280, 2_000_000, 31}, + {1080, 1920, 4_000_000, 40}, + } { + frames := patternFrames(t, seconds*30, tc.width, tc.height) + for _, codec := range []VideoCodec{VideoCodecVP8, VideoCodecH264} { + t.Run(fmt.Sprintf("%s/%dx%d", codec, tc.width, tc.height), func(t *testing.T) { + transcoder := NewFFmpegTranscoder("ffmpeg", testLogger(), metrics.New(), TranscoderOptions{ + VideoCodec: codec, WireFormat: config.WireFormatRaw, EncoderThreads: 1, + }) + input := make(chan frame, len(frames)) + for _, item := range frames { + input <- item + } + close(input) + output := make(chan frame, len(frames)) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := transcoder.EncodeStream(ctx, input, output); err != nil { + t.Fatalf("EncodeStream() error = %v", err) + } + close(output) + + count, total, largestKey := 0, 0, 0 + var width, height uint16 + var levelIDC byte + for item := range output { + count++ + total += len(item.data) + if codec == VideoCodecVP8 { + if w, h, err := vp8KeyframeDimensions(item.data); err == nil { + width, height = w, h + largestKey = max(largestKey, len(item.data)) + } + continue + } + for _, nalu := range splitAnnexBNALUs(item.data) { + if len(nalu) > 3 && nalu[0]&0x1f == 7 { + levelIDC = nalu[3] + if w, h, err := h264SPSDimensions(nalu); err == nil { + width, height = w, h + } + largestKey = max(largestKey, len(item.data)) + } + } + } + if count != len(frames) { + t.Fatalf("encoded %d frames, want %d", count, len(frames)) + } + if width != tc.width || height != tc.height { + t.Fatalf("encoded resolution=%dx%d, want %dx%d", width, height, tc.width, tc.height) + } + if codec == VideoCodecH264 && levelIDC != tc.levelIDC { + t.Fatalf("H.264 level_idc=%d, want %d", levelIDC, tc.levelIDC) + } + bitsPerSecond := total * 8 / seconds + if bitsPerSecond < tc.bitsPerSecond*6/10 || bitsPerSecond > tc.bitsPerSecond*13/10 { + t.Fatalf("bitrate=%d bit/s, want about %d", bitsPerSecond, tc.bitsPerSecond) + } + t.Logf("%s %dx%d: %d kbit/s, largest keyframe %d bytes", codec, width, height, bitsPerSecond/1000, largestKey) + }) + } + } +}