Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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을 별도로 검증해야 한다.

Expand Down
21 changes: 20 additions & 1 deletion internal/media/ffmpeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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...)
Expand Down
21 changes: 19 additions & 2 deletions internal/media/h264.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
)
Expand Down
47 changes: 47 additions & 0 deletions internal/media/h264_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package media

import (
"context"
"fmt"
"os/exec"
"strings"
"testing"
Expand Down Expand Up @@ -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")
})
}
}
93 changes: 55 additions & 38 deletions internal/media/h264_return_rate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,74 @@ package media

import (
"context"
"fmt"
"inno-live-server/internal/config"
"math/rand"
"os/exec"
"testing"
"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)
}
111 changes: 111 additions & 0 deletions internal/media/return_encoder_harness_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
}
Loading