Compare commits

...

5 Commits

Author SHA1 Message Date
3DRX
d8741c02e0 correct error handling and codec config 2025-07-03 21:44:07 +08:00
3DRX
29b9eaf317 resolve cr issues: tidy pkg-codec-ffmpeg-ci.yaml 2025-07-03 21:06:49 +08:00
3DRX
104cc5a5ab resolve cr issues: tidy ci 2025-07-03 21:06:49 +08:00
3DRX
32bfdbb52a resolve cr issues: tidy code 2025-07-03 21:06:49 +08:00
Jingyang Kang
00e120c79f Add codec ffmpeg 2025-07-03 21:06:49 +08:00
10 changed files with 1314 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
name: pkg/codec/ffmpeg CI
on:
pull_request:
branches:
- master
paths:
- pkg/codec/ffmepg
push:
branches:
- master
paths:
- pkg/codec/ffmepg
jobs:
build-linux:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go:
- '1.21' # oldest version this package supports
- '1.22' # oldstable Go version
- '1.23' # stable Go version
name: Linux Go ${{ matrix.go }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Install dependencies
run: |
sudo apt-get update -qq
sudo apt-get install --no-install-recommends -y \
libopus-dev \
libva-dev \
libvpx-dev \
libx11-dev \
libx264-dev \
libxext-dev \
nasm \
yasm
- name: Cache FFmpeg build
uses: actions/cache@v4
id: ffmpeg-cache
with:
path: |
pkg/codec/ffmpeg/tmp/n7.0
key: ffmpeg-linux-n7.0-${{ hashFiles('pkg/codec/ffmpeg/Makefile') }}
restore-keys: |
ffmpeg-linux-n7.0-
- name: Check if FFmpeg libraries exist
id: ffmpeg-check
working-directory: pkg/codec/ffmpeg
run: |
echo "=== Checking FFmpeg cache status ==="
if [ -f "tmp/n7.0/lib/libavcodec.a" ] && [ -f "tmp/n7.0/lib/pkgconfig/libavcodec.pc" ]; then
echo "FFmpeg libraries found in cache"
echo "ffmpeg_exists=true" >> $GITHUB_OUTPUT
else
echo "FFmpeg libraries missing or incomplete"
ls -la tmp/ 2>/dev/null || echo "tmp directory does not exist"
ls -la tmp/n7.0/ 2>/dev/null || echo "n7.0 directory does not exist"
ls -la tmp/n7.0/lib/ 2>/dev/null || echo "lib directory does not exist"
echo "ffmpeg_exists=false" >> $GITHUB_OUTPUT
fi
- name: Build FFmpeg (if not cached or incomplete)
if: steps.ffmpeg-cache.outputs.cache-hit != 'true' || steps.ffmpeg-check.outputs.ffmpeg_exists != 'true'
working-directory: pkg/codec/ffmpeg
run: make build-ffmpeg
- name: Verify FFmpeg installation
working-directory: pkg/codec/ffmpeg
run: |
ls -la tmp/n7.0/
ls -la tmp/n7.0/lib/ || echo "lib directory not found"
ls -la tmp/n7.0/include/ || echo "include directory not found"
pkg-config --exists --print-errors libavcodec || echo "pkg-config check failed"
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/pkg/codec/ffmpeg/tmp/n7.0/lib/pkgconfig
- name: Run pkg/codec/ffmpeg Test Suite
working-directory: pkg/codec/ffmpeg
run: |
make test
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/pkg/codec/ffmpeg/tmp/n7.0/lib/pkgconfig
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
build-darwin:
strategy:
fail-fast: false
matrix:
go:
- '1.22'
- '1.23'
runs-on: macos-latest
name: Darwin Go ${{ matrix.go }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Install dependencies
run: |
brew install \
pkg-config \
opus \
libvpx \
x264
- name: Cache FFmpeg build
uses: actions/cache@v4
id: ffmpeg-cache
with:
path: |
pkg/codec/ffmpeg/tmp/n7.0
key: ffmpeg-darwin-n7.0-${{ hashFiles('pkg/codec/ffmpeg/Makefile') }}
restore-keys: |
ffmpeg-darwin-n7.0-
- name: Check if FFmpeg libraries exist
id: ffmpeg-check
working-directory: pkg/codec/ffmpeg
run: |
if [ -f "tmp/n7.0/lib/libavcodec.a" ] && [ -f "tmp/n7.0/lib/pkgconfig/libavcodec.pc" ]; then
echo "ffmpeg_exists=true" >> $GITHUB_OUTPUT
else
echo "ffmpeg_exists=false" >> $GITHUB_OUTPUT
fi
- name: Build FFmpeg (if not cached or incomplete)
if: steps.ffmpeg-cache.outputs.cache-hit != 'true' || steps.ffmpeg-check.outputs.ffmpeg_exists != 'true'
working-directory: pkg/codec/ffmpeg
run: make build-ffmpeg
- name: Run Test Suite
working-directory: pkg/codec/ffmpeg
run: |
make test
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/pkg/codec/ffmpeg/tmp/n7.0/lib/pkgconfig
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
check-licenses:
runs-on: ubuntu-latest
name: Check Licenses
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Installing go-licenses
run: go install github.com/google/go-licenses@latest
- name: Checking licenses
run: go-licenses check ./...

View File

@@ -164,6 +164,24 @@ An open source API that allows applications such as VLC media player or GStreame
* Installation: * Installation:
* Ubuntu: `apt install libva-dev` * Ubuntu: `apt install libva-dev`
#### Video codecs implemented using ffmpeg
* Package: [github.com/pion/mediadevices/pkg/codec/ffmpeg](https://pkg.go.dev/github.com/pion/mediadevices/pkg/codec/ffmpeg)
* Installation: You need to enable CGO, and provide the ffmpeg headers and libraries when compiling. For more detail, checkout
https://github.com/asticode/go-astiav?tab=readme-ov-file#install-ffmpeg-from-source.
* NVENC: If you want to use nvenc, you need to install [FFmpeg/nv-codec-headers](https://github.com/FFmpeg/nv-codec-headers) too.
Make sure that your driver's version is supported by the nv-codec-headers version you are installing.
To install it, clone the repo, checkout to wanted version, and `sudo make install`.
> Currently, only ffmpeg n7.0 and n7.1 are supported.
##### nvenc
Requires ffmpeg build with `--enable-nonfree --enable-nvenc`.
##### x264
Requires ffmpeg build with `--enable-libx264 --enable-gpl`.
#### Audio Codecs #### Audio Codecs

1
pkg/codec/ffmpeg/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tmp

41
pkg/codec/ffmpeg/Makefile Normal file
View File

@@ -0,0 +1,41 @@
version=n7.0
srcPath=tmp/$(version)/src
installPath=tmp/$(version)
CGO_CFLAGS := -I$(CURDIR)/$(installPath)/include/
CGO_LDFLAGS := -L$(CURDIR)/$(installPath)/lib/
PKG_CONFIG_PATH := $(CURDIR)/$(installPath)/lib/pkgconfig
configure := --enable-libx264 --enable-gpl
# Main test target - depends on FFmpeg being built
test: $(installPath)/lib/libavcodec.a
PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test -v .
# Separate target for building FFmpeg (used by CI when cache miss)
build-ffmpeg: $(installPath)/lib/libavcodec.a
@echo "FFmpeg build completed"
# Clean incomplete builds before starting
clean-incomplete:
@if [ -d "$(srcPath)" ] && [ ! -f "$(installPath)/lib/libavcodec.a" ]; then \
echo "Cleaning incomplete build..."; \
rm -rf $(srcPath); \
fi
# FFmpeg build rule
$(installPath)/lib/libavcodec.a: clean-incomplete $(srcPath)/Makefile
cd $(srcPath) && make -j4
cd $(srcPath) && make install
@echo "Installation completed, checking results..."
@ls -la $(installPath)/lib/ || echo "lib directory not found"
@ls -la $(installPath)/include/ || echo "include directory not found"
$(srcPath)/Makefile: $(srcPath)/.git
cd $(srcPath) && ./configure --prefix=$(CURDIR)/$(installPath) $(configure)
$(srcPath)/.git:
rm -rf $(srcPath)
mkdir -p $(srcPath)
cd $(srcPath) && git clone https://github.com/FFmpeg/FFmpeg .
cd $(srcPath) && git checkout $(version)
.PHONY: test build-ffmpeg clean-incomplete

View File

@@ -0,0 +1,18 @@
package ffmpeg
import (
"errors"
)
var (
errFailedToCreateHwDevice = errors.New("ffmpeg: failed to create device")
errCodecNotFound = errors.New("ffmpeg: codec not found")
errFailedToCreateCodecCtx = errors.New("ffmpeg: failed to allocate codec context")
errFailedToCreateHwFramesCtx = errors.New("ffmpeg: failed to create hardware frames context")
errFailedToInitHwFramesCtx = errors.New("ffmpeg: failed to initialize hardware frames context")
errFailedToOpenCodecCtx = errors.New("ffmpeg: failed to open codec context")
errFailedToAllocFrame = errors.New("ffmpeg: failed to allocate frame")
errFailedToAllocSwBuf = errors.New("ffmpeg: failed to allocate software buffer")
errFailedToAllocHwBuf = errors.New("ffmpeg: failed to allocate hardware buffer")
errFailedToAllocPacket = errors.New("ffmpeg: failed to allocate packet")
)

439
pkg/codec/ffmpeg/ffmpeg.go Normal file
View File

@@ -0,0 +1,439 @@
// Package ffmpeg brings libavcodec's encoding capabilities to mediadevices.
// This package requires ffmpeg headers and libraries to be built.
// For more information, see https://github.com/asticode/go-astiav?tab=readme-ov-file#install-ffmpeg-from-source.
//
// Currently, only nvenc, x264, vaapi are implemented, but extending this to other ffmpeg supported codecs should
// be simple.
package ffmpeg
import (
"errors"
"io"
"sync"
"github.com/asticode/go-astiav"
"github.com/pion/mediadevices/pkg/codec"
"github.com/pion/mediadevices/pkg/io/video"
"github.com/pion/mediadevices/pkg/prop"
)
type baseEncoder struct {
codecCtx *astiav.CodecContext
frame *astiav.Frame
packet *astiav.Packet
width int
height int
r video.Reader
nextIsKeyFrame bool
mu sync.Mutex
closed bool
}
type hardwareEncoder struct {
baseEncoder
hwFramesCtx *astiav.HardwareFramesContext
hwFrame *astiav.Frame
}
type softwareEncoder struct {
baseEncoder
}
func newHardwareEncoder(r video.Reader, p prop.Media, params Params) (*hardwareEncoder, error) {
if p.FrameRate == 0 {
p.FrameRate = params.FrameRate
}
astiav.SetLogLevel(astiav.LogLevel(astiav.LogLevelWarning))
var hardwareDeviceType astiav.HardwareDeviceType
switch params.codecName {
case "h264_nvenc", "hevc_nvenc", "av1_nvenc":
hardwareDeviceType = astiav.HardwareDeviceType(astiav.HardwareDeviceTypeCUDA)
case "vp8_vaapi", "vp9_vaapi", "h264_vaapi", "hevc_vaapi":
hardwareDeviceType = astiav.HardwareDeviceType(astiav.HardwareDeviceTypeVAAPI)
}
hwDevice, err := astiav.CreateHardwareDeviceContext(
hardwareDeviceType,
params.hardwareDevice,
nil,
0,
)
if err != nil {
return nil, errFailedToCreateHwDevice
}
codec := astiav.FindEncoderByName(params.codecName)
if codec == nil {
return nil, errCodecNotFound
}
codecCtx := astiav.AllocCodecContext(codec)
if codecCtx == nil {
return nil, errFailedToCreateCodecCtx
}
// Configure codec context
codecCtx.SetWidth(p.Width)
codecCtx.SetHeight(p.Height)
codecCtx.SetTimeBase(astiav.NewRational(1, int(p.FrameRate)))
codecCtx.SetFramerate(codecCtx.TimeBase().Invert())
codecCtx.SetBitRate(int64(params.BitRate))
codecCtx.SetRateControlMaxRate(int64(params.BitRate + params.BitRate/10))
codecCtx.SetRateControlMinRate(int64(params.BitRate - params.BitRate/10))
codecCtx.SetGopSize(params.KeyFrameInterval)
codecCtx.SetMaxBFrames(0)
switch params.codecName {
case "h264_nvenc", "hevc_nvenc", "av1_nvenc":
codecCtx.SetPixelFormat(astiav.PixelFormat(astiav.PixelFormatCuda))
case "vp8_vaapi", "vp9_vaapi", "h264_vaapi", "hevc_vaapi":
codecCtx.SetPixelFormat(astiav.PixelFormat(astiav.PixelFormatVaapi))
}
codecOptions := codecCtx.PrivateData().Options()
switch params.codecName {
case "av1_nvenc":
codecCtx.SetProfile(astiav.Profile(astiav.ProfileAv1Main))
codecOptions.Set("tier", "0", 0)
case "h264_vaapi":
codecCtx.SetProfile(astiav.Profile(astiav.ProfileH264Main))
codecOptions.Set("profile", "main", 0)
codecOptions.Set("level", "1", 0)
case "hevc_vaapi":
codecCtx.SetProfile(astiav.Profile(astiav.ProfileHevcMain))
codecOptions.Set("profile", "main", 0)
codecOptions.Set("tier", "main", 0)
codecOptions.Set("level", "1", 0)
}
switch params.codecName {
case "h264_nvenc", "hevc_nvenc", "av1_nvenc":
codecOptions.Set("forced-idr", "1", 0)
codecOptions.Set("zerolatency", "1", 0)
codecOptions.Set("intra-refresh", "1", 0)
codecOptions.Set("delay", "0", 0)
codecOptions.Set("tune", "ll", 0)
codecOptions.Set("preset", "p1", 0)
codecOptions.Set("rc", "vbr", 0)
case "vp8_vaapi", "vp9_vaapi", "h264_vaapi", "hevc_vaapi":
codecOptions.Set("rc_mode", "CBR", 0)
}
// Create hardware frames context
hwFramesCtx := astiav.AllocHardwareFramesContext(hwDevice)
hwDevice.Free()
if hwFramesCtx == nil {
codecCtx.Free()
return nil, errFailedToCreateHwFramesCtx
}
// Set hardware frames context parameters
hwFramesCtx.SetWidth(p.Width)
hwFramesCtx.SetHeight(p.Height)
switch params.codecName {
case "h264_nvenc", "hevc_nvenc", "av1_nvenc":
hwFramesCtx.SetHardwarePixelFormat(astiav.PixelFormat(astiav.PixelFormatCuda))
case "vp8_vaapi", "vp9_vaapi", "h264_vaapi", "hevc_vaapi":
hwFramesCtx.SetHardwarePixelFormat(astiav.PixelFormat(astiav.PixelFormatVaapi))
}
hwFramesCtx.SetSoftwarePixelFormat(params.pixelFormat)
if err = hwFramesCtx.Initialize(); err != nil {
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToInitHwFramesCtx
}
codecCtx.SetHardwareFramesContext(hwFramesCtx)
// Open codec context
if err = codecCtx.Open(codec, nil); err != nil {
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToOpenCodecCtx
}
softwareFrame := astiav.AllocFrame()
if softwareFrame == nil {
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToAllocFrame
}
softwareFrame.SetWidth(p.Width)
softwareFrame.SetHeight(p.Height)
softwareFrame.SetPixelFormat(params.pixelFormat)
if err = softwareFrame.AllocBuffer(0); err != nil {
softwareFrame.Free()
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToAllocSwBuf
}
hardwareFrame := astiav.AllocFrame()
if err = hardwareFrame.AllocHardwareBuffer(hwFramesCtx); err != nil {
softwareFrame.Free()
hardwareFrame.Free()
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToAllocHwBuf
}
packet := astiav.AllocPacket()
if packet == nil {
softwareFrame.Free()
hardwareFrame.Free()
codecCtx.Free()
hwFramesCtx.Free()
return nil, errFailedToAllocPacket
}
return &hardwareEncoder{
baseEncoder: baseEncoder{
codecCtx: codecCtx,
frame: softwareFrame,
packet: packet,
width: p.Width,
height: p.Height,
r: r,
nextIsKeyFrame: false,
},
hwFramesCtx: hwFramesCtx,
hwFrame: hardwareFrame,
}, nil
}
func (e *hardwareEncoder) Controller() codec.EncoderController {
return e
}
func (e *hardwareEncoder) Read() ([]byte, func(), error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, func() {}, io.EOF
}
img, release, err := e.r.Read()
if err != nil {
return nil, func() {}, err
}
defer release()
if e.nextIsKeyFrame {
e.frame.SetPictureType(astiav.PictureType(astiav.PictureTypeI))
e.hwFrame.SetPictureType(astiav.PictureType(astiav.PictureTypeI))
e.nextIsKeyFrame = false
} else {
e.frame.SetPictureType(astiav.PictureType(astiav.PictureTypeNone))
e.hwFrame.SetPictureType(astiav.PictureType(astiav.PictureTypeNone))
}
if err = e.frame.Data().FromImage(img); err != nil {
return nil, func() {}, err
}
if err = e.frame.TransferHardwareData(e.hwFrame); err != nil {
return nil, func() {}, err
}
if err = e.codecCtx.SendFrame(e.hwFrame); err != nil {
return nil, func() {}, err
}
if err = e.codecCtx.ReceivePacket(e.packet); err != nil {
return nil, func() {}, err
}
data := make([]byte, e.packet.Size())
copy(data, e.packet.Data())
e.packet.Unref()
return data, func() {}, nil
}
// ForceKeyFrame forces the next frame to be encoded as a keyframe
func (e *hardwareEncoder) ForceKeyFrame() error {
e.mu.Lock()
defer e.mu.Unlock()
e.nextIsKeyFrame = true
return nil
}
func (e *hardwareEncoder) SetBitRate(bitrate int) error {
e.mu.Lock()
defer e.mu.Unlock()
e.codecCtx.SetBitRate(int64(bitrate))
e.codecCtx.SetRateControlMaxRate(int64(bitrate + bitrate/10))
e.codecCtx.SetRateControlMinRate(int64(bitrate - bitrate/10))
return nil
}
func (e *hardwareEncoder) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.packet != nil {
e.packet.Free()
}
if e.frame != nil {
e.frame.Free()
}
if e.hwFrame != nil {
e.hwFrame.Free()
}
if e.codecCtx != nil {
e.codecCtx.Free()
}
if e.hwFramesCtx != nil {
e.hwFramesCtx.Free()
}
e.closed = true
return nil
}
func newSoftwareEncoder(r video.Reader, p prop.Media, params Params) (*softwareEncoder, error) {
if p.FrameRate == 0 {
p.FrameRate = params.FrameRate
}
astiav.SetLogLevel(astiav.LogLevel(astiav.LogLevelWarning))
codec := astiav.FindEncoderByName(params.codecName)
if codec == nil {
return nil, errCodecNotFound
}
codecCtx := astiav.AllocCodecContext(codec)
if codecCtx == nil {
return nil, errFailedToCreateCodecCtx
}
// Configure codec context
codecCtx.SetWidth(p.Width)
codecCtx.SetHeight(p.Height)
codecCtx.SetTimeBase(astiav.NewRational(1, int(p.FrameRate)))
codecCtx.SetFramerate(codecCtx.TimeBase().Invert())
codecCtx.SetPixelFormat(astiav.PixelFormat(astiav.PixelFormatYuv420P))
codecCtx.SetBitRate(int64(params.BitRate))
codecCtx.SetGopSize(params.KeyFrameInterval)
codecCtx.SetMaxBFrames(0)
codecOptions := codecCtx.PrivateData().Options()
codecOptions.Set("preset", "ultrafast", 0)
codecOptions.Set("tune", "zerolatency", 0)
codecCtx.SetFlags(astiav.CodecContextFlags(astiav.CodecContextFlagLowDelay))
// Open codec context
if err := codecCtx.Open(codec, nil); err != nil {
codecCtx.Free()
return nil, errFailedToOpenCodecCtx
}
softwareFrame := astiav.AllocFrame()
if softwareFrame == nil {
codecCtx.Free()
return nil, errFailedToAllocFrame
}
softwareFrame.SetWidth(p.Width)
softwareFrame.SetHeight(p.Height)
softwareFrame.SetPixelFormat(astiav.PixelFormat(astiav.PixelFormatYuv420P))
if err := softwareFrame.AllocBuffer(0); err != nil {
softwareFrame.Free()
codecCtx.Free()
return nil, errFailedToAllocSwBuf
}
packet := astiav.AllocPacket()
if packet == nil {
softwareFrame.Free()
codecCtx.Free()
return nil, errFailedToAllocPacket
}
return &softwareEncoder{
baseEncoder: baseEncoder{
codecCtx: codecCtx,
frame: softwareFrame,
packet: packet,
width: p.Width,
height: p.Height,
r: video.ToI420(r),
nextIsKeyFrame: false,
},
}, nil
}
func (e *softwareEncoder) Read() ([]byte, func(), error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, func() {}, io.EOF
}
img, release, err := e.r.Read()
if err != nil {
return nil, func() {}, err
}
defer release()
if e.nextIsKeyFrame {
e.frame.SetPictureType(astiav.PictureType(astiav.PictureTypeI))
e.nextIsKeyFrame = false
} else {
e.frame.SetPictureType(astiav.PictureType(astiav.PictureTypeNone))
}
if err = e.frame.Data().FromImage(img); err != nil {
return nil, func() {}, err
}
if err = e.codecCtx.SendFrame(e.frame); err != nil {
return nil, func() {}, err
}
for {
if err = e.codecCtx.ReceivePacket(e.packet); err != nil {
if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) {
continue
}
return nil, func() {}, err
}
break
}
data := make([]byte, e.packet.Size())
copy(data, e.packet.Data())
e.packet.Unref()
return data, func() {}, nil
}
func (e *softwareEncoder) Controller() codec.EncoderController {
return e
}
func (e *softwareEncoder) ForceKeyFrame() error {
e.mu.Lock()
defer e.mu.Unlock()
e.nextIsKeyFrame = true
return nil
}
func (e *softwareEncoder) SetBitRate(bitrate int) error {
e.mu.Lock()
defer e.mu.Unlock()
e.codecCtx.SetBitRate(int64(bitrate))
return nil
}
func (e *softwareEncoder) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.packet != nil {
e.packet.Free()
}
if e.frame != nil {
e.frame.Free()
}
if e.codecCtx != nil {
e.codecCtx.Free()
}
e.closed = true
return nil
}

View File

@@ -0,0 +1,357 @@
package ffmpeg
import (
"context"
"image"
"io"
"sync/atomic"
"testing"
"time"
"github.com/pion/mediadevices/pkg/codec"
"github.com/pion/mediadevices/pkg/codec/internal/codectest"
"github.com/pion/mediadevices/pkg/frame"
"github.com/pion/mediadevices/pkg/io/video"
"github.com/pion/mediadevices/pkg/prop"
)
func TestEncoder(t *testing.T) {
for name, factory := range map[string]func() (codec.VideoEncoderBuilder, error){
"x264": func() (codec.VideoEncoderBuilder, error) {
p, err := NewH264X264Params()
p.FrameRate = 30
p.BitRate = 1000000
p.KeyFrameInterval = 60
return &p, err
},
} {
factory := factory
t.Run(name, func(t *testing.T) {
t.Run("SimpleRead", func(t *testing.T) {
p, err := factory()
if err != nil {
t.Fatal(err)
}
codectest.VideoEncoderSimpleReadTest(t, p,
prop.Media{
Video: prop.Video{
Width: 256,
Height: 144,
FrameFormat: frame.FormatI420,
},
},
image.NewYCbCr(
image.Rect(0, 0, 256, 144),
image.YCbCrSubsampleRatio420,
),
)
})
t.Run("CloseTwice", func(t *testing.T) {
p, err := factory()
if err != nil {
t.Fatal(err)
}
codectest.VideoEncoderCloseTwiceTest(t, p, prop.Media{
Video: prop.Video{
Width: 640,
Height: 480,
FrameRate: 30,
FrameFormat: frame.FormatI420,
},
})
})
t.Run("ReadAfterClose", func(t *testing.T) {
p, err := factory()
if err != nil {
t.Fatal(err)
}
codectest.VideoEncoderReadAfterCloseTest(t, p,
prop.Media{
Video: prop.Video{
Width: 256,
Height: 144,
FrameFormat: frame.FormatI420,
},
},
image.NewYCbCr(
image.Rect(0, 0, 256, 144),
image.YCbCrSubsampleRatio420,
),
)
})
})
}
}
func TestImageSizeChange(t *testing.T) {
t.Skip("Changing image size on the fly is currently not supported")
for name, factory := range map[string]func() (codec.VideoEncoderBuilder, error){
"x264": func() (codec.VideoEncoderBuilder, error) {
p, err := NewH264X264Params()
p.FrameRate = 30
p.BitRate = 1000000
p.KeyFrameInterval = 60
return &p, err
},
} {
factory := factory
t.Run(name, func(t *testing.T) {
param, err := factory()
if err != nil {
t.Fatal(err)
}
for name, testCase := range map[string]struct {
initialWidth, initialHeight int
width, height int
}{
"NoChange": {
320, 240,
320, 240,
},
"Enlarge": {
320, 240,
640, 480,
},
"Shrink": {
640, 480,
320, 240,
},
} {
testCase := testCase
t.Run(name, func(t *testing.T) {
var cnt uint32
r, err := param.BuildVideoEncoder(
video.ReaderFunc(func() (image.Image, func(), error) {
i := atomic.AddUint32(&cnt, 1)
if i == 1 {
return image.NewYCbCr(
image.Rect(0, 0, testCase.width, testCase.height),
image.YCbCrSubsampleRatio420,
), func() {}, nil
}
return nil, nil, io.EOF
}),
prop.Media{
Video: prop.Video{
Width: testCase.initialWidth,
Height: testCase.initialHeight,
FrameRate: 1,
FrameFormat: frame.FormatI420,
},
},
)
if err != nil {
t.Fatal(err)
}
_, rel, err := r.Read()
if err != nil {
t.Fatal(err)
}
rel()
_, _, err = r.Read()
if err != io.EOF {
t.Fatal(err)
}
})
}
})
}
}
func TestRequestKeyFrame(t *testing.T) {
for name, factory := range map[string]func() (codec.VideoEncoderBuilder, error){
"x264": func() (codec.VideoEncoderBuilder, error) {
p, err := NewH264X264Params()
p.FrameRate = 30
p.BitRate = 1000000
p.KeyFrameInterval = 60
return &p, err
},
} {
factory := factory
t.Run(name, func(t *testing.T) {
param, err := factory()
if err != nil {
t.Fatal(err)
}
var initialWidth, initialHeight, width, height int = 320, 240, 320, 240
var cnt uint32
r, err := param.BuildVideoEncoder(
video.ReaderFunc(func() (image.Image, func(), error) {
i := atomic.AddUint32(&cnt, 1)
if i == 3 {
return nil, nil, io.EOF
}
return image.NewYCbCr(
image.Rect(0, 0, width, height),
image.YCbCrSubsampleRatio420,
), func() {}, nil
}),
prop.Media{
Video: prop.Video{
Width: initialWidth,
Height: initialHeight,
FrameRate: 1,
FrameFormat: frame.FormatI420,
},
},
)
if err != nil {
t.Fatal(err)
}
_, rel, err := r.Read()
if err != nil {
t.Fatal(err)
}
rel()
r.Controller().(codec.KeyFrameController).ForceKeyFrame()
_, rel, err = r.Read()
if err != nil {
t.Fatal(err)
}
// TODO: check if this is a key frame
// if !r.(*encoder).isKeyFrame {
// t.Fatal("Not a key frame")
// }
rel()
_, _, err = r.Read()
if err != io.EOF {
t.Fatal(err)
}
})
}
}
func TestSetBitrate(t *testing.T) {
for name, factory := range map[string]func() (codec.VideoEncoderBuilder, error){
"x264": func() (codec.VideoEncoderBuilder, error) {
p, err := NewH264X264Params()
p.FrameRate = 30
p.BitRate = 1000000
p.KeyFrameInterval = 60
return &p, err
},
} {
factory := factory
t.Run(name, func(t *testing.T) {
param, err := factory()
if err != nil {
t.Fatal(err)
}
var initialWidth, initialHeight, width, height int = 320, 240, 320, 240
var cnt uint32
r, err := param.BuildVideoEncoder(
video.ReaderFunc(func() (image.Image, func(), error) {
i := atomic.AddUint32(&cnt, 1)
if i == 3 {
return nil, nil, io.EOF
}
return image.NewYCbCr(
image.Rect(0, 0, width, height),
image.YCbCrSubsampleRatio420,
), func() {}, nil
}),
prop.Media{
Video: prop.Video{
Width: initialWidth,
Height: initialHeight,
FrameRate: 1,
FrameFormat: frame.FormatI420,
},
},
)
if err != nil {
t.Fatal(err)
}
_, rel, err := r.Read()
if err != nil {
t.Fatal(err)
}
rel()
err = r.Controller().(codec.BitRateController).SetBitRate(1000) // 1000 bit/second is ridiculously low, but this is a testcase.
if err != nil {
t.Fatal(err)
}
_, rel, err = r.Read()
if err != nil {
t.Fatal(err)
}
rel()
_, _, err = r.Read()
if err != io.EOF {
t.Fatal(err)
}
})
}
}
func TestShouldImplementBitRateControl(t *testing.T) {
e := &softwareEncoder{}
if _, ok := e.Controller().(codec.BitRateController); !ok {
t.Error()
}
}
func TestShouldImplementKeyFrameControl(t *testing.T) {
e := &softwareEncoder{}
if _, ok := e.Controller().(codec.KeyFrameController); !ok {
t.Error()
}
}
func TestEncoderFrameMonotonic(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
params, err := NewH264X264Params()
params.FrameRate = 30
params.BitRate = 1000000
params.KeyFrameInterval = 60
if err != nil {
t.Fatal(err)
}
encoder, err := params.BuildVideoEncoder(
video.ReaderFunc(func() (image.Image, func(), error) {
return image.NewYCbCr(
image.Rect(0, 0, 320, 240),
image.YCbCrSubsampleRatio420,
), func() {}, nil
},
), prop.Media{
Video: prop.Video{
Width: 320,
Height: 240,
FrameRate: 30,
FrameFormat: frame.FormatI420,
},
})
if err != nil {
t.Fatal(err)
}
ticker := time.NewTicker(33 * time.Millisecond)
defer ticker.Stop()
ctxx, cancell := context.WithCancel(ctx)
defer cancell()
for {
select {
case <-ctxx.Done():
return
case <-ticker.C:
_, rel, err := encoder.Read()
if err != nil {
t.Fatal(err)
}
rel()
}
}
}

36
pkg/codec/ffmpeg/go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/pion/mediadevices/pkg/codec/ffmpeg
go 1.21
replace github.com/pion/mediadevices => ../../../
require (
github.com/asticode/go-astiav v0.35.1
github.com/pion/mediadevices v0.7.1
)
require (
github.com/asticode/go-astikit v0.42.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.15 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.11 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.1.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

56
pkg/codec/ffmpeg/go.sum Normal file
View File

@@ -0,0 +1,56 @@
github.com/asticode/go-astiav v0.35.1 h1:jq27Ihf+GXtOTnhzNTcpKrW1iLNRAuPSoarh7/SapYc=
github.com/asticode/go-astiav v0.35.1/go.mod h1:K7D8UC6GeQt85FUxk2KVwYxHnotrxuEnp5evkkudc2s=
github.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w=
github.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.15 h1:MuhuGn1cxpVCPLNY1lI7F1tQ8Spntpgf12ob+pOYT8s=
github.com/pion/rtp v1.8.15/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI=
github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.1.0 h1:yq/p0G5nKGbHISf0YKNA8Yk+kmijbblBvuSLwaJ4QYg=
github.com/pion/webrtc/v4 v4.1.0/go.mod h1:cgEGkcpxGkT6Di2ClBYO5lP9mFXbCfEOrkYUpjjCQO4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

191
pkg/codec/ffmpeg/params.go Normal file
View File

@@ -0,0 +1,191 @@
package ffmpeg
import (
"github.com/asticode/go-astiav"
"github.com/pion/mediadevices/pkg/codec"
"github.com/pion/mediadevices/pkg/io/video"
"github.com/pion/mediadevices/pkg/prop"
)
type Params struct {
codec.BaseParams
codecName string
hardwareDevice string
pixelFormat astiav.PixelFormat
FrameRate float32
}
type VP8Params struct {
Params
}
func NewVP8VAAPIParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (VP8Params, error) {
return VP8Params{
Params: Params{
codecName: "vp8_vaapi",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func (p *VP8Params) RTPCodec() *codec.RTPCodec {
return codec.NewRTPVP8Codec(90000)
}
func (p *VP8Params) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newHardwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}
type VP9Params struct {
Params
}
func NewVP9VAAPIParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (VP8Params, error) {
return VP8Params{
Params: Params{
codecName: "vp9_vaapi",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func (p *VP9Params) RTPCodec() *codec.RTPCodec {
return codec.NewRTPVP9Codec(90000)
}
func (p *VP9Params) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newHardwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}
type H264Params struct {
Params
}
func NewH264NVENCParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (H264Params, error) {
return H264Params{
Params: Params{
codecName: "h264_nvenc",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func NewH264VAAPIParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (H264Params, error) {
return H264Params{
Params: Params{
codecName: "h264_vaapi",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
// RTPCodec represents the codec metadata
func (p *H264Params) RTPCodec() *codec.RTPCodec {
return codec.NewRTPH264Codec(90000)
}
func (p *H264Params) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newHardwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}
type H264SoftwareParams struct {
Params
}
func NewH264X264Params() (H264SoftwareParams, error) {
return H264SoftwareParams{
Params: Params{
codecName: "libx264",
},
}, nil
}
func (p *H264SoftwareParams) RTPCodec() *codec.RTPCodec {
return codec.NewRTPH264Codec(90000)
}
func (p *H264SoftwareParams) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newSoftwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}
type H265Params struct {
Params
}
func NewH265NVENCParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (H265Params, error) {
return H265Params{
Params: Params{
codecName: "hevc_nvenc",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func NewH265VAAPIParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (H265Params, error) {
return H265Params{
Params: Params{
codecName: "hevc_vaapi",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func (p *H265Params) RTPCodec() *codec.RTPCodec {
return codec.NewRTPH265Codec(90000)
}
func (p *H265Params) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newHardwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}
type AV1Params struct {
Params
}
func NewAV1NVENCParams(hardwareDevice string, pixelFormat astiav.PixelFormat) (AV1Params, error) {
return AV1Params{
Params: Params{
codecName: "av1_nvenc",
hardwareDevice: hardwareDevice,
pixelFormat: pixelFormat,
},
}, nil
}
func (p *AV1Params) RTPCodec() *codec.RTPCodec {
return codec.NewRTPAV1Codec(90000)
}
func (p *AV1Params) BuildVideoEncoder(r video.Reader, property prop.Media) (codec.ReadCloser, error) {
readCloser, err := newHardwareEncoder(r, property, p.Params)
if err != nil {
return nil, err
}
return readCloser, nil
}