Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
407 lines
9.2 KiB
Go
407 lines
9.2 KiB
Go
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Package ivfwriter implements IVF media container writer
|
|
package ivfwriter
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
|
|
"github.com/pion/rtp"
|
|
"github.com/pion/rtp/codecs"
|
|
"github.com/pion/rtp/codecs/av1/obu"
|
|
)
|
|
|
|
var (
|
|
errFileNotOpened = errors.New("file not opened")
|
|
errInvalidNilPacket = errors.New("invalid nil packet")
|
|
errCodecUnset = errors.New("codec is unset")
|
|
errCodecAlreadySet = errors.New("codec is already set")
|
|
errNoSuchCodec = errors.New("no codec for this MimeType")
|
|
errInvalidMediaTimebase = errors.New("invalid media timebase")
|
|
)
|
|
|
|
type (
|
|
codec int
|
|
|
|
// IVFWriter is used to take RTP packets and write them to an IVF on disk.
|
|
IVFWriter struct {
|
|
ioWriter io.Writer
|
|
count uint64
|
|
seenKeyFrame bool
|
|
|
|
codec codec
|
|
|
|
timebaseDenominator uint32
|
|
timebaseNumerator uint32
|
|
firstFrameTimestamp uint32
|
|
clockRate uint64
|
|
videoWidth uint16
|
|
videoHeight uint16
|
|
|
|
directPTS bool
|
|
|
|
// VP8, VP9
|
|
currentFrame []byte
|
|
|
|
// AV1
|
|
av1Depacketizer *codecs.AV1Depacketizer
|
|
}
|
|
)
|
|
|
|
const (
|
|
codecUnset codec = iota
|
|
codecVP8
|
|
codecVP9
|
|
codecAV1
|
|
|
|
mimeTypeVP8 = "video/VP8"
|
|
mimeTypeVP9 = "video/VP9"
|
|
mimeTypeAV1 = "video/AV1"
|
|
)
|
|
|
|
// New builds a new IVF writer.
|
|
func New(fileName string, opts ...Option) (*IVFWriter, error) {
|
|
file, err := os.Create(fileName) //nolint:gosec
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writer, err := NewWith(file, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writer.ioWriter = file
|
|
|
|
return writer, nil
|
|
}
|
|
|
|
// NewWith initialize a new IVF writer with an io.Writer output.
|
|
func NewWith(out io.Writer, opts ...Option) (*IVFWriter, error) {
|
|
if out == nil {
|
|
return nil, errFileNotOpened
|
|
}
|
|
|
|
writer := &IVFWriter{
|
|
ioWriter: out,
|
|
seenKeyFrame: false,
|
|
timebaseDenominator: 30,
|
|
timebaseNumerator: 1,
|
|
clockRate: 90000,
|
|
videoWidth: 640,
|
|
videoHeight: 480,
|
|
}
|
|
|
|
for _, o := range opts {
|
|
if err := o(writer); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if writer.codec == codecUnset {
|
|
writer.codec = codecVP8
|
|
}
|
|
if err := writer.writeHeader(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if writer.timebaseDenominator == 0 {
|
|
return nil, errInvalidMediaTimebase
|
|
}
|
|
|
|
return writer, nil
|
|
}
|
|
|
|
func (i *IVFWriter) writeHeader() error {
|
|
header := make([]byte, 32)
|
|
copy(header[0:], "DKIF") // DKIF
|
|
binary.LittleEndian.PutUint16(header[4:], 0) // Version
|
|
binary.LittleEndian.PutUint16(header[6:], 32) // Header size
|
|
|
|
// FOURCC
|
|
switch i.codec {
|
|
case codecVP8:
|
|
copy(header[8:], "VP80")
|
|
case codecVP9:
|
|
copy(header[8:], "VP90")
|
|
case codecAV1:
|
|
copy(header[8:], "AV01")
|
|
default:
|
|
return errCodecUnset
|
|
}
|
|
|
|
binary.LittleEndian.PutUint16(header[12:], i.videoWidth) // Width in pixels
|
|
binary.LittleEndian.PutUint16(header[14:], i.videoHeight) // Height in pixels
|
|
binary.LittleEndian.PutUint32(header[16:], i.timebaseDenominator) // Framerate denominator
|
|
binary.LittleEndian.PutUint32(header[20:], i.timebaseNumerator) // Framerate numerator
|
|
binary.LittleEndian.PutUint32(header[24:], 900) // Frame count, will be updated on first Close() call
|
|
binary.LittleEndian.PutUint32(header[28:], 0) // Unused
|
|
|
|
_, err := i.ioWriter.Write(header)
|
|
|
|
return err
|
|
}
|
|
|
|
func (i *IVFWriter) timestampToPts(timestamp uint64) uint64 {
|
|
return timestamp * uint64(i.timebaseNumerator) / uint64(i.timebaseDenominator)
|
|
}
|
|
|
|
func (i *IVFWriter) writeFrame(frame []byte, timestamp uint64) error {
|
|
frameHeader := make([]byte, 12)
|
|
//nolint:gosec // G115
|
|
binary.LittleEndian.PutUint32(frameHeader[0:], uint32(len(frame))) // Frame length
|
|
|
|
var pts uint64
|
|
if i.directPTS {
|
|
// Direct PTS mode: use timestamp directly as PTS.
|
|
pts = timestamp
|
|
} else {
|
|
// Existing behavior: convert using timebase.
|
|
pts = i.timestampToPts(timestamp)
|
|
}
|
|
binary.LittleEndian.PutUint64(frameHeader[4:], pts) // PTS
|
|
|
|
i.count++
|
|
|
|
if _, err := i.ioWriter.Write(frameHeader); err != nil {
|
|
return err
|
|
}
|
|
_, err := i.ioWriter.Write(frame)
|
|
|
|
return err
|
|
}
|
|
|
|
// WriteRTP adds a new packet and writes the appropriate headers for it.
|
|
func (i *IVFWriter) WriteRTP(packet *rtp.Packet) error {
|
|
if i.ioWriter == nil {
|
|
return errFileNotOpened
|
|
} else if len(packet.Payload) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if i.count == 0 {
|
|
i.firstFrameTimestamp = packet.Timestamp
|
|
}
|
|
|
|
var timestamp uint64
|
|
if i.directPTS {
|
|
// Direct PTS mode: use RTP timestamp directly (no millisecond conversion).
|
|
timestamp = uint64(packet.Timestamp - i.firstFrameTimestamp)
|
|
} else {
|
|
// Existing behavior: convert to milliseconds first.
|
|
timestamp = 1000 * uint64(packet.Timestamp-i.firstFrameTimestamp) / i.clockRate
|
|
}
|
|
|
|
switch i.codec {
|
|
case codecVP8:
|
|
return i.writeVP8(packet, timestamp)
|
|
case codecVP9:
|
|
return i.writeVP9(packet, timestamp)
|
|
case codecAV1:
|
|
return i.writeAV1(packet, timestamp)
|
|
default:
|
|
return errCodecUnset
|
|
}
|
|
}
|
|
|
|
func (i *IVFWriter) writeVP8(packet *rtp.Packet, timestamp uint64) error {
|
|
vp8Packet := codecs.VP8Packet{}
|
|
if _, err := vp8Packet.Unmarshal(packet.Payload); err != nil {
|
|
return err
|
|
}
|
|
|
|
// a payload that carries only the VP8 descriptor has nothing to write.
|
|
if len(vp8Packet.Payload) == 0 {
|
|
return nil
|
|
}
|
|
|
|
isKeyFrame := (vp8Packet.Payload[0] & 0x01) == 0
|
|
switch {
|
|
case !i.seenKeyFrame && !isKeyFrame:
|
|
return nil
|
|
case i.currentFrame == nil && vp8Packet.S != 1:
|
|
return nil
|
|
}
|
|
|
|
i.seenKeyFrame = true
|
|
i.currentFrame = append(i.currentFrame, vp8Packet.Payload[0:]...)
|
|
|
|
if !packet.Marker {
|
|
return nil
|
|
} else if len(i.currentFrame) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := i.writeFrame(i.currentFrame, timestamp); err != nil {
|
|
return err
|
|
}
|
|
i.currentFrame = nil
|
|
|
|
return nil
|
|
}
|
|
|
|
func (i *IVFWriter) writeVP9(packet *rtp.Packet, timestamp uint64) error {
|
|
vp9Packet := codecs.VP9Packet{}
|
|
if _, err := vp9Packet.Unmarshal(packet.Payload); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch {
|
|
case !i.seenKeyFrame && vp9Packet.P:
|
|
return nil
|
|
case i.currentFrame == nil && !vp9Packet.B:
|
|
return nil
|
|
}
|
|
|
|
i.seenKeyFrame = true
|
|
i.currentFrame = append(i.currentFrame, vp9Packet.Payload[0:]...)
|
|
|
|
if !packet.Marker {
|
|
return nil
|
|
} else if len(i.currentFrame) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// the timestamp must be sequential. webrtc mandates a clock rate of 90000
|
|
// and we've assumed 30fps in the header.
|
|
if err := i.writeFrame(i.currentFrame, timestamp); err != nil {
|
|
return err
|
|
}
|
|
i.currentFrame = nil
|
|
|
|
return nil
|
|
}
|
|
|
|
func (i *IVFWriter) writeAV1(packet *rtp.Packet, timestamp uint64) error {
|
|
if i.av1Depacketizer == nil {
|
|
i.av1Depacketizer = &codecs.AV1Depacketizer{}
|
|
}
|
|
|
|
payload, err := i.av1Depacketizer.Unmarshal(packet.Payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !i.seenKeyFrame {
|
|
isKeyFrame := i.av1Depacketizer.N || (len(payload) > 0 && obu.Type((payload[0]&0x78)>>3) == obu.OBUSequenceHeader)
|
|
if !isKeyFrame {
|
|
return nil
|
|
}
|
|
|
|
i.seenKeyFrame = true
|
|
}
|
|
|
|
i.currentFrame = append(i.currentFrame, payload...)
|
|
if !packet.Marker {
|
|
return nil
|
|
}
|
|
|
|
delimiter := obu.Header{
|
|
Type: obu.OBUTemporalDelimiter,
|
|
HasSizeField: true,
|
|
}
|
|
frame := append(delimiter.Marshal(), 0)
|
|
frame = append(frame, i.currentFrame...)
|
|
|
|
if err := i.writeFrame(frame, timestamp); err != nil {
|
|
return err
|
|
}
|
|
i.currentFrame = nil
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close stops the recording.
|
|
func (i *IVFWriter) Close() error {
|
|
if i.ioWriter == nil {
|
|
// Returns no error as it may be convenient to call
|
|
// Close() multiple times
|
|
return nil
|
|
}
|
|
|
|
defer func() {
|
|
i.ioWriter = nil
|
|
}()
|
|
|
|
if ws, ok := i.ioWriter.(io.WriteSeeker); ok {
|
|
// Update the framecount
|
|
if _, err := ws.Seek(24, 0); err != nil {
|
|
return err
|
|
}
|
|
buff := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(buff, uint32(i.count)) //nolint:gosec // G115
|
|
if _, err := ws.Write(buff); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if closer, ok := i.ioWriter.(io.Closer); ok {
|
|
return closer.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// An Option configures a SampleBuilder.
|
|
type Option func(i *IVFWriter) error
|
|
|
|
// WithCodec configures if IVFWriter is writing AV1 or VP8 packets to disk.
|
|
func WithCodec(mimeType string) Option {
|
|
return func(i *IVFWriter) error {
|
|
if i.codec != codecUnset {
|
|
return errCodecAlreadySet
|
|
}
|
|
|
|
switch mimeType {
|
|
case mimeTypeVP8:
|
|
i.codec = codecVP8
|
|
case mimeTypeVP9:
|
|
i.codec = codecVP9
|
|
case mimeTypeAV1:
|
|
i.codec = codecAV1
|
|
default:
|
|
return errNoSuchCodec
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithWidthAndHeight(width, height uint16) Option {
|
|
return func(i *IVFWriter) error {
|
|
i.videoWidth = width
|
|
i.videoHeight = height
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithFrameRate(numerator, denominator uint32) Option {
|
|
return func(i *IVFWriter) error {
|
|
i.timebaseNumerator = numerator
|
|
i.timebaseDenominator = denominator
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithDirectPTS enables direct use of RTP timestamps as PTS values
|
|
// without millisecond conversion.
|
|
//
|
|
// When this option is used, RTP timestamps are written directly as PTS values,
|
|
// preserving full timestamp precision. Use WithFrameRate to set the appropriate
|
|
// timebase (e.g., WithFrameRate(1, 90000) for standard 90kHz RTP clock).
|
|
//
|
|
// Example usage for standard RTP video (90kHz clock rate):
|
|
//
|
|
// NewWith(file, WithFrameRate(1, 90000), WithDirectPTS())
|
|
func WithDirectPTS() Option {
|
|
return func(i *IVFWriter) error {
|
|
i.directPTS = true
|
|
|
|
return nil
|
|
}
|
|
}
|