Skip to content

Commit

Permalink
Add play-from-disk-mkv
Browse files Browse the repository at this point in the history
Reads Opus + H264 from MKV and sends via WebRTC
  • Loading branch information
Sean-Der committed May 8, 2024
1 parent bcca656 commit f9a61e8
Show file tree
Hide file tree
Showing 3 changed files with 323 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ node_modules/
#############
*.ivf
*.ogg
*.mkv
*.h264
tags
cover.out
*.sw[poe]
Expand Down
35 changes: 35 additions & 0 deletions play-from-disk-mkv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# play-from-disk-mkv
play-from-disk-mkv demonstrates how to send video and/or audio to your browser from a MKV file

This example has the same structure as [play-from-disk](https://github.com/pion/webrtc/tree/master/examples/play-from-disk) but instead reads from a MKV file

## Instructions
### Create a MKV with a H264 + Opus track
```
ffmpeg -i $INPUT_FILE -c:v libx264 -b:v 2M -max_delay 0 -bf 0 -g 30 -c:a libopus -page_duration 20000 output.mkv
```

### Download play-from-disk-mkv
```
go install github.com/pion/example-webrtc-applications/v3/play-from-disk-mkv@latest
```

### Open play-from-disk-mkv example page
[jsfiddle.net](https://jsfiddle.net/8qvzh6ue/) you should see two text-areas and a 'Start Session' button

### Run play-from-disk-mkv with your browsers SessionDescription as stdin
The `output.mkv` you created should be in the same directory as `play-from-disk-mkv`. In the jsfiddle the top textarea is your browser, copy that and:

#### Linux/macOS
Run `echo $BROWSER_SDP | play-from-disk-mkv`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `play-from-disk-mkv < my_file`

### Input play-from-disk-mkv's SessionDescription into your browser
Copy the text that `play-from-disk-mkv` just emitted and copy into second text area

### Hit 'Start Session' in jsfiddle, enjoy your video!
A video should start playing in your browser above the input boxes. `play-from-disk-mkv` will exit when the file reaches the end

Congrats, you have used Pion WebRTC! Now start building something cool
286 changes: 286 additions & 0 deletions play-from-disk-mkv/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

//go:build !js
// +build !js

// play-from-disk-mkv demonstrates how to send video and/or audio to your browser from a MKV file saved to disk
package main

import (
"encoding/binary"
"fmt"
"os"
"time"

"github.com/at-wat/ebml-go"
"github.com/at-wat/ebml-go/webm"
"github.com/pion/example-webrtc-applications/v3/internal/signal"
"github.com/pion/webrtc/v3"
"github.com/pion/webrtc/v3/pkg/media"
)

const (
mkvFileName = "output.mkv"

spsCountOffset = 5

naluTypeBitmask = 0x1F
spsID = 0x67
ppsID = 0x68
)

// nolint: gochecknoglobals
var annexBPrefix = []byte{0x00, 0x00, 0x01}

// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
func rtcpReader(rtpSender *webrtc.RTPSender) {
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
}

// Write the audio samples to the video and audio track. Record how long we have been sleeping
// time.Sleep may sleep longer then expected
func chanToTrack(sampleChan chan media.Sample, track *webrtc.TrackLocalStaticSample) {
var (
sleepWanted time.Duration
sleepStart time.Time
)

for s := range sampleChan {
if err := track.WriteSample(s); err != nil {
panic(err)
}

sleepDebt := sleepWanted - time.Since(sleepStart)
sleepStart, sleepWanted = time.Now(), s.Duration
time.Sleep(s.Duration + sleepDebt)
}
}

func sendMkv(mkvFile *os.File, audioTrack, videoTrack *webrtc.TrackLocalStaticSample) {
var unmarshaled struct {
Header webm.EBMLHeader `ebml:"EBML"`
Segment webm.Segment `ebml:"Segment"`
}

// Parse the MKV file into memory
if err := ebml.Unmarshal(mkvFile, &unmarshaled); err != nil {
panic(err)
}

var (
audioTrackNumber, videoTrackNumber uint64
lastAudioTimeCode, lastVideoTimeCode uint64
oldTimeCode uint64
spsAndPPS []byte
)

audioQueue, videoQueue := make(chan media.Sample, 10), make(chan media.Sample, 10)
go chanToTrack(audioQueue, audioTrack)
go chanToTrack(videoQueue, videoTrack)

// Get the ID associated with the Audio+Video track. This is used latter when
// actually processing the media packets
for _, t := range unmarshaled.Segment.Tracks.TrackEntry {
switch t.CodecID {
case "V_MPEG4/ISO/AVC":
videoTrackNumber = t.TrackNumber
spsAndPPS = extractMetadata(t.CodecPrivate)
case "A_OPUS":
audioTrackNumber = t.TrackNumber
}
}

if audioTrackNumber == 0 || videoTrackNumber == 0 {
panic("MKV file must contain one H264 and one Opus Track")
}

// Loop the entire file and convert nanosecond timestamps to Durations
// and push onto channels. These channels pace the send of audio and video
for _, cluster := range unmarshaled.Segment.Cluster {
for _, block := range cluster.SimpleBlock {
timecode := (cluster.Timecode + uint64(block.Timecode)) * unmarshaled.Segment.Info.TimecodeScale

if block.TrackNumber == videoTrackNumber {
// Convert H264 from AVC bitstream to Annex-B
annexBSlice := []byte{}

// Metadata around the stream is stored in Matroska Header
if block.Keyframe {
annexBSlice = append(annexBSlice, spsAndPPS...)
}

for {
if len(block.Data[0]) == 0 {
break
}

naluSize := binary.BigEndian.Uint32(block.Data[0])
block.Data[0] = block.Data[0][4:]

annexBSlice = append(annexBSlice, annexBPrefix...)
annexBSlice = append(annexBSlice, block.Data[0][:naluSize]...)

block.Data[0] = block.Data[0][naluSize:]
}

// Send to video goroutine for paced sending
lastVideoTimeCode, oldTimeCode = timecode, lastVideoTimeCode
videoQueue <- media.Sample{Data: annexBSlice, Duration: time.Duration(timecode - oldTimeCode)}
} else {
// Send to audio goroutine for paced sending
lastAudioTimeCode, oldTimeCode = timecode, lastAudioTimeCode
audioQueue <- media.Sample{Data: block.Data[0], Duration: time.Duration(timecode - oldTimeCode)}
}
}
}
}

func main() { //nolint
// Assert that we have an audio or video file
_, err := os.Stat(mkvFileName)
if os.IsNotExist(err) {
panic("Could not find `" + mkvFileName + "`")
}

// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()

// Create a Audio Track
audioTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion")
if err != nil {
panic(err)
}

rtpSender, err := peerConnection.AddTrack(audioTrack)
if err != nil {
panic(err)
}
rtcpReader(rtpSender)

// Create a Video Track
videoTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "pion")
if err != nil {
panic(err)
}

rtpSender, err = peerConnection.AddTrack(videoTrack)
if err != nil {
panic(err)
}
rtcpReader(rtpSender)

mkvFile, err := os.Open(mkvFileName)
if err != nil {
panic(err)
}
defer func() {
if closeErr := mkvFile.Close(); closeErr != nil {
panic(closeErr)
}
}()

// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", s.String())

if s == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure. It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
})

// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
signal.Decode(signal.MustReadStdin(), &offer)

// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}

// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}

// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)

// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}

// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete

// Output the answer in base64 so we can paste it in browser
fmt.Println(signal.Encode(*peerConnection.LocalDescription()))

// Read from the MKV and write the Audio and Video tracks
sendMkv(mkvFile, audioTrack, videoTrack)
}

// Convert AVC Extradata to Annex-B SPS and PPS
func extractMetadata(codecData []byte) (out []byte) {
spsCount := codecData[spsCountOffset] & naluTypeBitmask
offset := 6
for i := 0; i < int(spsCount); i++ {
spsLen := binary.BigEndian.Uint16(codecData[offset : offset+2])
offset += 2
if codecData[offset] != spsID {
panic("Failed to parse SPS")
}

out = append(out, annexBPrefix...)
out = append(out, codecData[offset:offset+int(spsLen)]...)
offset += int(spsLen)
}

ppsCount := codecData[offset]
offset++
for i := 0; i < int(ppsCount); i++ {
ppsLen := binary.BigEndian.Uint16(codecData[offset : offset+2])
offset += 2
if codecData[offset] != ppsID {
panic("Failed to parse PPS")
}

out = append(out, annexBPrefix...)
out = append(out, codecData[offset:offset+int(ppsLen)]...)
offset += int(ppsLen)
}

return
}

0 comments on commit f9a61e8

Please sign in to comment.