diff --git a/examples/cmdsource/.gitignore b/examples/cmdsource/.gitignore new file mode 100644 index 00000000..72dfa0d7 --- /dev/null +++ b/examples/cmdsource/.gitignore @@ -0,0 +1,2 @@ +cmdsource +recorded.h264 diff --git a/examples/cmdsource/README.md b/examples/cmdsource/README.md new file mode 100644 index 00000000..72f1732e --- /dev/null +++ b/examples/cmdsource/README.md @@ -0,0 +1,45 @@ +## Instructions + +This example is nearly the same as the archive example, but uses the output of a shell command (in this case ffmpeg) as a video input instead of a camera. See the other examples for how to take this track and use or stream it. + +### Install required codecs + +In this example, we'll be using x264 as our video codec. We also use FFMPEG to generate the test video stream. (Note that cmdsource does not requre ffmpeg to run, it is just what this example uses) Therefore, we need to make sure that these are installed within our system. + +Installation steps: + +* [ffmpeg](https://ffmpeg.org/) +* [x264](https://github.com/pion/mediadevices#x264) + +### Download cmdsource example + +``` +git clone https://github.com/pion/mediadevices.git +``` + +### Run cmdsource example + +Run `cd mediadevices/examples/cmdsource && go build && ./cmdsource recorded.h264` + +To stop recording, press `Ctrl+c` or send a SIGINT signal. + +### Playback recorded video + +Use ffplay (part of the ffmpeg project): +``` +ffplay -f h264 recorded.h264 +``` + +Or install GStreamer and run: +``` +gst-launch-1.0 playbin uri=file://${PWD}/recorded.h264 +``` + +Or run VLC media plyer: +``` +vlc recorded.h264 +``` + +A video should start playing in a window. + +Congrats, you have used pion-MediaDevices! Now start building something cool diff --git a/examples/cmdsource/main.go b/examples/cmdsource/main.go new file mode 100644 index 00000000..1b5ee1b5 --- /dev/null +++ b/examples/cmdsource/main.go @@ -0,0 +1,139 @@ +package main + +// !!!! This example requires ffmpeg to be installed !!!! + +import ( + "errors" + "fmt" + "image" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/pion/mediadevices" + "github.com/pion/mediadevices/pkg/codec/x264" // This is required to use H264 video encoder + "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/driver/cmdsource" + "github.com/pion/mediadevices/pkg/frame" + "github.com/pion/mediadevices/pkg/io/video" + "github.com/pion/mediadevices/pkg/prop" +) + +// handy correlation between the names of frame formats in pion media devices and the same -pix_fmt as passed to ffmpeg +var ffmpegFrameFormatMap = map[frame.Format]string{ + frame.FormatI420: "yuv420p", + frame.FormatNV21: "nv21", + frame.FormatNV12: "nv12", + frame.FormatYUY2: "yuyv422", + frame.FormatUYVY: "uyvy422", + frame.FormatZ16: "gray", +} + +func ffmpegTestPatternCmd(width int, height int, frameRate float32, frameFormat frame.Format) string { + // returns the (command-line) command to tell the ffmpeg program to output a test video stream with the given pixel format, size and framerate to stdout: + return fmt.Sprintf("ffmpeg -hide_banner -f lavfi -i testsrc=size=%dx%d:rate=%f -vf realtime -f rawvideo -pix_fmt %s -", width, height, frameRate, ffmpegFrameFormatMap[frameFormat]) +} + +func getMediaDevicesDriverId(label string) (string, error) { + drivers := driver.GetManager().Query(func(d driver.Driver) bool { + return d.Info().Label == label + }) + if len(drivers) == 0 { + return "", errors.New("Failed to find the media devices driver for device label: " + label) + } + return drivers[0].ID(), nil +} + +func must(err error) { + if err != nil { + panic(err) + } +} + +func main() { + if len(os.Args) != 2 { + fmt.Printf("usage: %s \n", os.Args[0]) + return + } + dest := os.Args[1] + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT) + + x264Params, err := x264.NewParams() + must(err) + x264Params.Preset = x264.PresetMedium + x264Params.BitRate = 1_000_000 // 1mbps + + codecSelector := mediadevices.NewCodecSelector( + mediadevices.WithVideoEncoders(&x264Params), + ) + + // configure source video properties (raw video stream format that we should expect the command to output) + label := "My Cool Video" + videoProps := prop.Media{ + Video: prop.Video{ + Width: 640, + Height: 480, + FrameFormat: frame.FormatI420, + FrameRate: 30, + }, + // OR Audio: prop.Audio{} + } + + // Add the command source: + cmdString := ffmpegTestPatternCmd(videoProps.Video.Width, videoProps.Video.Height, videoProps.Video.FrameRate, videoProps.FrameFormat) + err = cmdsource.AddVideoCmdSource(label, cmdString, []prop.Media{videoProps}, 10, true) + must(err) + + // Now your video command source will be a driver in mediaDevices: + driverId, err := getMediaDevicesDriverId(label) + must(err) + + mediaStream, err := mediadevices.GetUserMedia(mediadevices.MediaStreamConstraints{ + Video: func(c *mediadevices.MediaTrackConstraints) { + c.DeviceID = prop.String(driverId) + }, + Codec: codecSelector, + }) + must(err) + + videoTrack := mediaStream.GetVideoTracks()[0].(*mediadevices.VideoTrack) + defer videoTrack.Close() + //// --- OR (if the track was setup as audio) -- + // audioTrack := mediaStream.GetAudioTracks()[0].(*mediadevices.AudioTrack) + // defer audioTrack.Close() + + // Do whatever you want with the track, the rest of this example is the same as the archive example: + // ================================================================================================= + + videoTrack.Transform(video.TransformFunc(func(r video.Reader) video.Reader { + return video.ReaderFunc(func() (img image.Image, release func(), err error) { + // we send io.EOF signal to the encoder reader to stop reading. Therefore, io.Copy + // will finish its execution and the program will finish + select { + case <-sigs: + return nil, func() {}, io.EOF + default: + } + + return r.Read() + }) + })) + + reader, err := videoTrack.NewEncodedIOReader(x264Params.RTPCodec().MimeType) + must(err) + defer reader.Close() + + out, err := os.Create(dest) + must(err) + + fmt.Println("Recording... Press Ctrl+c to stop") + _, err = io.Copy(out, reader) + must(err) + videoTrack.Close() // Ideally we should close the track before the io.Copy is done to save every last frame + <-time.After(100 * time.Millisecond) // Give a bit of time for the ffmpeg stream to stop cleanly before the program exits + fmt.Println("Your video has been recorded to", dest) +} diff --git a/examples/go.sum b/examples/go.sum index ff87b27f..716c5185 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -27,6 +27,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= diff --git a/go.mod b/go.mod index 90246d42..e6374553 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/blackjack/webcam v0.0.0-20220329180758-ba064708e165 github.com/gen2brain/malgo v0.11.10 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.3.0 github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329 github.com/pion/interceptor v0.1.12 diff --git a/go.sum b/go.sum index 6217c7b3..50b25001 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= diff --git a/pkg/driver/cmdsource/audiocmd.go b/pkg/driver/cmdsource/audiocmd.go new file mode 100644 index 00000000..6cea66b0 --- /dev/null +++ b/pkg/driver/cmdsource/audiocmd.go @@ -0,0 +1,126 @@ +package cmdsource + +import ( + "encoding/binary" + "fmt" + "io" + "time" + + "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/io/audio" + "github.com/pion/mediadevices/pkg/prop" + "github.com/pion/mediadevices/pkg/wave" +) + +type audioCmdSource struct { + cmdSource + bufferSampleCount int + showStdErr bool + label string +} + +func AddAudioCmdSource(label string, command string, mediaProperties []prop.Media, readTimeout uint32, sampleBufferSize int, showStdErr bool) error { + audioCmdSource := &audioCmdSource{ + cmdSource: newCmdSource(command, mediaProperties, readTimeout), + bufferSampleCount: sampleBufferSize, + label: label, + showStdErr: showStdErr, + } + if len(audioCmdSource.cmdArgs) == 0 || audioCmdSource.cmdArgs[0] == "" { + return errInvalidCommand // no command specified + } + + // register this audio source with the driver manager + err := driver.GetManager().Register(audioCmdSource, driver.Info{ + Label: label, + DeviceType: driver.CmdSource, + Priority: driver.PriorityNormal, + }) + if err != nil { + return err + } + return nil +} + +func (c *audioCmdSource) AudioRecord(inputProp prop.Media) (audio.Reader, error) { + decoder, err := wave.NewDecoder(&wave.RawFormat{ + SampleSize: inputProp.SampleSize, + IsFloat: inputProp.IsFloat, + Interleaved: inputProp.IsInterleaved, + }) + + if err != nil { + return nil, err + } + + if c.showStdErr { + // get the command's standard error + stdErr, err := c.execCmd.StderrPipe() + if err != nil { + return nil, err + } + // send standard error to the console as debug logs prefixed with "{command} stdErr >" + go c.logStdIoWithPrefix(fmt.Sprintf("%s stderr > ", c.label+":"+c.cmdArgs[0]), stdErr) + } + + // get the command's standard output + stdOut, err := c.execCmd.StdoutPipe() + if err != nil { + return nil, err + } + + // add environment variables to the command for each media property + c.addEnvVarsFromStruct(inputProp.Audio, c.showStdErr) + + // start the command + if err := c.execCmd.Start(); err != nil { + return nil, err + } + + // claclulate the sample size and chunk buffer size (as a multple of the sample size) + sampleSize := inputProp.ChannelCount * inputProp.SampleSize + chunkSize := c.bufferSampleCount * sampleSize + var endienness binary.ByteOrder = binary.LittleEndian + if inputProp.IsBigEndian { + endienness = binary.BigEndian + } + + var chunkBuf []byte = make([]byte, chunkSize) + doneChan := make(chan error) + r := audio.ReaderFunc(func() (chunk wave.Audio, release func(), err error) { + go func() { + if _, err := io.ReadFull(stdOut, chunkBuf); err == io.ErrUnexpectedEOF { + doneChan <- io.EOF + } else if err != nil { + doneChan <- err + } + doneChan <- nil + }() + + select { + case err := <-doneChan: + if err != nil { + return nil, nil, err + } else { + decodedChunk, err := decoder.Decode(endienness, chunkBuf, inputProp.ChannelCount) + if err != nil { + return nil, nil, err + } + // FIXME: the decoder should also fill this information + switch decodedChunk := decodedChunk.(type) { + case *wave.Float32Interleaved: + decodedChunk.Size.SamplingRate = inputProp.SampleRate + case *wave.Int16Interleaved: + decodedChunk.Size.SamplingRate = inputProp.SampleRate + default: + panic("unsupported format") + } + return decodedChunk, func() {}, err + } + case <-time.After(time.Duration(c.readTimeout) * time.Second): + return nil, func() {}, errReadTimeout + } + }) + + return r, nil +} diff --git a/pkg/driver/cmdsource/audiocmd_test.go b/pkg/driver/cmdsource/audiocmd_test.go new file mode 100644 index 00000000..96d6e88a --- /dev/null +++ b/pkg/driver/cmdsource/audiocmd_test.go @@ -0,0 +1,151 @@ +package cmdsource + +import ( + "fmt" + "os/exec" + "testing" + + "github.com/pion/mediadevices/pkg/prop" +) + +// const minInt32 int32 = -2147483648 +const maxInt32 int32 = 2147418112 + +func ValueInRange(input int64, min int64, max int64) bool { + return input >= min && input <= max +} + +var ffmpegAudioFormatMap = map[string]prop.Audio{ + "f32be": { + IsFloat: true, + IsBigEndian: true, + IsInterleaved: true, + SampleSize: 4, // 4*8 = 32 bits + }, + "f32le": { + IsFloat: true, + IsBigEndian: false, + IsInterleaved: true, + SampleSize: 4, // 4*8 = 32 bits + }, + "s16be": { + IsFloat: false, + IsBigEndian: true, + IsInterleaved: true, + SampleSize: 2, // 2*8 = 16 bits + }, + "s16le": { + IsFloat: false, + IsBigEndian: false, + IsInterleaved: true, + SampleSize: 2, // 2*8 = 16 bits + }, +} + +func RunAudioCmdTest(t *testing.T, freq int, duration float32, sampleRate int, channelCount int, sampleBufferSize int, format string) { + command := fmt.Sprintf("ffmpeg -f lavfi -i sine=frequency=%d:duration=%f:sample_rate=%d -af arealtime,volume=8 -ac %d -f %s -", freq, duration, sampleRate, channelCount, format) + timeout := uint32(10) // 10 seconds + audioProps := ffmpegAudioFormatMap[format] + audioProps.ChannelCount = channelCount + audioProps.SampleRate = sampleRate + properties := []prop.Media{ + { + DeviceID: "ffmpeg audio", + Audio: audioProps, + }, + } + + fmt.Println("Testing audio source command: " + command) + + // Make sure ffmpeg is installed before continuting the test + err := exec.Command("ffmpeg", "-version").Run() + if err != nil { + t.Skip("ffmpeg command not found in path. Skipping test. Err: ", err) + } + + // Create the audio cmd source + audioCmdSource := &audioCmdSource{ + cmdSource: newCmdSource(command, properties, timeout), + bufferSampleCount: sampleBufferSize, + } + + // check if the command split correctly + if audioCmdSource.cmdArgs[0] != "ffmpeg" { + t.Fatal("command parsing failed") + } + + err = audioCmdSource.Open() + if err != nil { + t.Fatal(err) + } + defer audioCmdSource.Close() + + // Get the audio reader from the audio cmd source + reader, err := audioCmdSource.AudioRecord(properties[0]) + if err != nil { + t.Fatal(err) + } + + // Read the first chunk + chunk, _, err := reader.Read() + if err != nil { + t.Fatal(err) + } + + // Check if the chunk has the correct number of channels + if chunk.ChunkInfo().Channels != channelCount { + t.Errorf("chunk has incorrect number of channels") + } + + // Check if the chunk has the correct sample rate + if chunk.ChunkInfo().SamplingRate != sampleRate { + t.Errorf("chunk has incorrect sample rate") + } + + println("Samples:") + for i := 0; i < chunk.ChunkInfo().Len; i++ { + fmt.Printf("%d\n", chunk.At(i, 0).Int()) + } + + // Test the waveform value at the 1st sample in the chunk (should be "near" 0, because it is a sine wave) + sampleIdx := 1 + channelIdx := 0 + min := int64(0) + max := int64(267911168) + if value := chunk.At(sampleIdx, channelIdx).Int(); ValueInRange(value, min, max) == false { + t.Errorf("chan #%d, chunk #%d has incorrect value, expected %d-%d, got %d", channelIdx, sampleIdx, min, max, value) + } + + // Test the waveform value at the 1/4th the way through the sine wave (should be near max in 32 bit int) + samplesPerSinewaveCycle := sampleRate / freq + sampleIdx = samplesPerSinewaveCycle / 4 // 1/4 of a cycle + channelIdx = 0 + min = int64(maxInt32) - int64(267911168) + max = 0xFFFFFFFF + if value := chunk.At(sampleIdx, channelIdx).Int(); ValueInRange(value, min, max) == false { + t.Errorf("chan #%d, chunk #%d has incorrect value, expected %d-%d, got %d", channelIdx, sampleIdx, min, max, value) + } + + err = audioCmdSource.Close() + if err != nil && err.Error() != "exit status 255" { // ffmpeg returns 255 when it is stopped normally + t.Fatal(err) + } + + audioCmdSource.Close() // should not panic +} + +func TestWavIntLeAudioCmdOut(t *testing.T) { + RunAudioCmdTest(t, 440, 1, 44100, 1, 256, "s16le") +} + +func TestWavIntBeAudioCmdOut(t *testing.T) { + RunAudioCmdTest(t, 120, 1, 44101, 1, 256, "s16be") +} + +func TestWavFloatLeAudioCmdOut(t *testing.T) { + RunAudioCmdTest(t, 220, 1, 44102, 1, 256, "f32le") +} + +func TestWavFloatBeAudioCmdOut(t *testing.T) { + RunAudioCmdTest(t, 110, 1, 44103, 1, 256, "f32be") +} diff --git a/pkg/driver/cmdsource/cmdsource.go b/pkg/driver/cmdsource/cmdsource.go new file mode 100644 index 00000000..e04c8cef --- /dev/null +++ b/pkg/driver/cmdsource/cmdsource.go @@ -0,0 +1,114 @@ +package cmdsource + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "reflect" + "strings" + "time" + + "github.com/google/shlex" + "github.com/pion/mediadevices/internal/logging" + "github.com/pion/mediadevices/pkg/prop" +) + +var ( + errReadTimeout = errors.New("read timeout") + errInvalidCommand = errors.New("invalid command") + errUnsupportedFormat = errors.New("Unsupported frame format, no frame size function found") +) + +var logger = logging.NewLogger("mediadevices/driver/cmdsource") + +type cmdSource struct { + cmdArgs []string + props []prop.Media + readTimeout uint32 // in seconds + execCmd *exec.Cmd +} + +func init() { + // No init. Call AddVideoCmdSource() or AddAudioCmdSource() to add a command source before calling getUserMedia(). +} + +func newCmdSource(command string, mediaProperties []prop.Media, readTimeout uint32) cmdSource { + cmdArgs, err := shlex.Split(command) // split command string on whitespace, respecting quotes & comments + if err != nil { + panic(errInvalidCommand) + } + return cmdSource{ + cmdArgs: cmdArgs, + props: mediaProperties, + readTimeout: readTimeout, + } +} + +func (c *cmdSource) Open() error { + c.execCmd = exec.Command(c.cmdArgs[0], c.cmdArgs[1:]...) + return nil +} + +func (c *cmdSource) Close() error { + if c.execCmd == nil || c.execCmd.Process == nil { + return nil + } + + _ = c.execCmd.Process.Signal(os.Interrupt) // send SIGINT to process to stop it + done := make(chan error) + go func() { done <- c.execCmd.Wait() }() + select { + case err := <-done: + return err // command exited normally or with an error code + case <-time.After(3 * time.Second): + err := c.execCmd.Process.Kill() // command timed out, kill it & return error + return err + } + +} + +func (c *cmdSource) Properties() []prop.Media { + return c.props +} + +// {BLOCKING GOROUTINE} logStdIoWithPrefix reads from the command's standard output or error, and prints it to the console as debug logs prefixed with the provided prefix +func (c *cmdSource) logStdIoWithPrefix(prefix string, stdIo io.ReadCloser) { + reader := bufio.NewReader(stdIo) + for { + if line, err := reader.ReadBytes('\n'); err == nil { + // logger.Debug(prefix + string(line)) + println(prefix + strings.Trim(string(line), " \r\n")) + } else if err == io.EOF || err == io.ErrUnexpectedEOF { + // logger.Debug(prefix + string(line)) + println(prefix + string(line)) + break + } else if err != nil { + logger.Error(err.Error()) + break + } + } +} + +func (c *cmdSource) addEnvVarsFromStruct(props interface{}, logProps bool) { + c.execCmd.Env = os.Environ() // inherit environment variables + values := reflect.ValueOf(props) + types := values.Type() + if logProps { + fmt.Print("Adding cmdsource environment variables: ") + } + for i := 0; i < values.NumField(); i++ { + name := types.Field(i).Name + value := values.Field(i) + envVar := fmt.Sprintf("PION_MEDIA_%s=%v", name, value) + if logProps { + fmt.Print(envVar + ", ") + } + c.execCmd.Env = append(c.execCmd.Env, envVar) + } + if logProps { + fmt.Println() + } +} diff --git a/pkg/driver/cmdsource/videocmd.go b/pkg/driver/cmdsource/videocmd.go new file mode 100644 index 00000000..ffea1703 --- /dev/null +++ b/pkg/driver/cmdsource/videocmd.go @@ -0,0 +1,103 @@ +package cmdsource + +import ( + "fmt" + "image" + "io" + "time" + + "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/frame" + "github.com/pion/mediadevices/pkg/io/video" + "github.com/pion/mediadevices/pkg/prop" +) + +type videoCmdSource struct { + cmdSource + showStdErr bool + label string +} + +func AddVideoCmdSource(label string, command string, mediaProperties []prop.Media, readTimeout uint32, showStdErr bool) error { + videoCmdSource := &videoCmdSource{ + cmdSource: newCmdSource(command, mediaProperties, readTimeout), + label: label, + showStdErr: showStdErr, + } + if len(videoCmdSource.cmdArgs) == 0 || videoCmdSource.cmdArgs[0] == "" { + return errInvalidCommand // no command specified + } + + err := driver.GetManager().Register(videoCmdSource, driver.Info{ + Label: label, + DeviceType: driver.CmdSource, + Priority: driver.PriorityNormal, + }) + if err != nil { + return err + } + return nil +} + +func (c *videoCmdSource) VideoRecord(inputProp prop.Media) (video.Reader, error) { + getFrameSize, ok := frame.FrameSizeMap[inputProp.FrameFormat] + if !ok { + return nil, errUnsupportedFormat + } + frameSize := getFrameSize(inputProp.Width, inputProp.Height) + + decoder, err := frame.NewDecoder(inputProp.FrameFormat) + if err != nil { + return nil, err + } + + if c.showStdErr { + // get the command's standard error + stdErr, err := c.execCmd.StderrPipe() + if err != nil { + return nil, err + } + // send standard error to the console as debug logs prefixed with "{command} stdErr >" + go c.logStdIoWithPrefix(fmt.Sprintf("%s stdErr> ", c.label+":"+c.cmdArgs[0]), stdErr) + } + // get the command's standard output + stdOut, err := c.execCmd.StdoutPipe() + if err != nil { + return nil, err + } + + // add environment variables to the command for each media property + c.addEnvVarsFromStruct(inputProp.Video, c.showStdErr) + + // start the command + if err := c.execCmd.Start(); err != nil { + return nil, err + } + + var buf []byte = make([]byte, frameSize) + doneChan := make(chan error) + // fmt.Printf("frameSize: %d\n", frameSize) + r := video.ReaderFunc(func() (img image.Image, release func(), err error) { + go func() { + if _, err := io.ReadFull(stdOut, buf); err == io.ErrUnexpectedEOF { + doneChan <- io.EOF + } else if err != nil { + doneChan <- err + } + doneChan <- nil + }() + + select { + case err := <-doneChan: + if err != nil { + return nil, nil, err + } else { + return decoder.Decode(buf, inputProp.Width, inputProp.Height) + } + case <-time.After(time.Duration(c.readTimeout) * time.Second): + return nil, func() {}, errReadTimeout + } + }) + + return r, nil +} diff --git a/pkg/driver/cmdsource/videocmd_test.go b/pkg/driver/cmdsource/videocmd_test.go new file mode 100644 index 00000000..5d9760b6 --- /dev/null +++ b/pkg/driver/cmdsource/videocmd_test.go @@ -0,0 +1,131 @@ +package cmdsource + +import ( + "fmt" + "image/color" + "os/exec" + "testing" + + "github.com/pion/mediadevices/pkg/frame" + "github.com/pion/mediadevices/pkg/prop" +) + +// var ycbcrWhite := color.YCbCr{235, 128, 128} +var ycbcrPink = color.YCbCr{Y: 198, Cb: 123, Cr: 155} +var ffmpegFrameFormatMap = map[frame.Format]string{ + frame.FormatI420: "yuv420p", + frame.FormatNV21: "nv21", + frame.FormatNV12: "nv12", + frame.FormatYUY2: "yuyv422", + frame.FormatUYVY: "uyvy422", + frame.FormatZ16: "gray", +} + +func RunVideoCmdTest(t *testing.T, width int, height int, frameRate float32, frameFormat frame.Format, inputColor string, expectedColor color.Color) { + + command := fmt.Sprintf("ffmpeg -hide_banner -f lavfi -i color=c=%s:size=%dx%d:rate=%f -vf realtime -f rawvideo -pix_fmt %s -", inputColor, width, height, frameRate, ffmpegFrameFormatMap[frameFormat]) + + // Example using injected environment variables instead of hardcoding the command: + // command := fmt.Sprintf("sh -c 'ffmpeg -hide_banner -f lavfi -i color=c=%s:size=\"$MEDIA_DEVICES_Width\"x\"$MEDIA_DEVICES_Height\":rate=\"$MEDIA_DEVICES_FrameRate\" -vf realtime -f rawvideo -pix_fmt %s -'", inputColor, ffmpegFrameFormatMap[frameFormat]) + + timeout := uint32(10) // 10 seconds + properties := []prop.Media{ + { + DeviceID: "ffmpeg 1", + Video: prop.Video{ + Width: width, + Height: height, + FrameFormat: frameFormat, + FrameRate: frameRate, + }, + }, + } + + fmt.Println("Testing video source command: " + command) + + // Make sure ffmpeg is installed before continuting the test + err := exec.Command("ffmpeg", "-version").Run() + if err != nil { + t.Skip("ffmpeg command not found in path. Skipping test. Err: ", err) + } + + // Create a new video command source + videoCmdSource := &videoCmdSource{ + cmdSource: newCmdSource(command, properties, timeout), + showStdErr: true, + label: "test_source", + } + + // if videoCmdSource.cmdArgs[0] != "ffmpeg" { + // t.Fatal("command parsing failed") + // } + + err = videoCmdSource.Open() + if err != nil { + t.Fatal(err) + } + defer videoCmdSource.Close() + + reader, err := videoCmdSource.VideoRecord(properties[0]) + if err != nil { + t.Fatal(err) + } + img, _, err := reader.Read() + if err != nil { + t.Fatal(err) + } + if img.Bounds().Dx() != width || img.Bounds().Dy() != height { + t.Logf("image resolution output is not correct, got: (%d, %d) | expected: (%d, %d)", img.Bounds().Dx(), img.Bounds().Dy(), width, height) + t.Fatal() + } + + // test color at upper left corner + if pxlColor := img.At(0, 0); pxlColor != expectedColor { + t.Errorf("Image pixel output at 0,0 is not correct. Got: %+v | Expected: %+v", pxlColor, expectedColor) + } + + // test color at center of image + x := width / 2 + y := height / 2 + if pxlColor := img.At(x, y); pxlColor != expectedColor { + t.Errorf("Image pixel output at %d,%d is not correct. Got: %+v | Expected: %+v", x, y, pxlColor, expectedColor) + } + + // test color at lower right corner + x = width - 1 + y = height - 1 + if pxlColor := img.At(x, y); pxlColor != expectedColor { + t.Errorf("Image pixel output at %d,%d is not correct. Got: %+v | Expected: %+v", x, y, pxlColor, expectedColor) + } + + err = videoCmdSource.Close() + if err != nil { + t.Fatal(err) + } + videoCmdSource.Close() // should not panic + println() // add a new line to separate the output from the end of the test +} + +func TestI420VideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatI420, "pink", ycbcrPink) +} + +func TestNV21VideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatNV21, "pink", ycbcrPink) +} + +func TestNV12VideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatNV12, "pink", ycbcrPink) +} + +func TestYUY2VideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatYUY2, "pink", ycbcrPink) +} + +func TestUYVYVideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatUYVY, "pink", ycbcrPink) +} + +func TestZ16VideoCmdOut(t *testing.T) { + RunVideoCmdTest(t, 640, 480, 30, frame.FormatZ16, "white", color.Gray16{65535}) +} diff --git a/pkg/driver/const.go b/pkg/driver/const.go index ec2d9cbb..2780104b 100644 --- a/pkg/driver/const.go +++ b/pkg/driver/const.go @@ -11,4 +11,6 @@ const ( Microphone = "microphone" // Screen represents screen devices Screen = "screen" + // CmdSource represents command sources + CmdSource = "cmdsource" ) diff --git a/pkg/frame/framesize.go b/pkg/frame/framesize.go new file mode 100644 index 00000000..852097c3 --- /dev/null +++ b/pkg/frame/framesize.go @@ -0,0 +1,45 @@ +package frame + +// Return a function to get the number of bytes a frame will occupy in the given format +var FrameSizeMap = map[Format]frameSizeFunc{ + FormatI420: frameSizeI420, + FormatNV21: frameSizeNV21, + FormatNV12: frameSizeNV21, // NV12 and NV21 have the same frame size + FormatYUY2: frameSizeYUY2, + FormatUYVY: frameSizeYUY2, // UYVY and YUY2 have the same frame size + FormatMJPEG: frameSizeMJPEG, + FormatZ16: frameSizeZ16, +} + +type frameSizeFunc func(width, height int) uint + +func frameSizeYUY2(width, height int) uint { + yi := width * height + // ci := yi / 2 + // fi := yi + 2*ci + fi := 2 * yi + return uint(fi) +} + +func frameSizeI420(width, height int) uint { + yi := width * height + cbi := yi + width*height/4 + cri := cbi + width*height/4 + return uint(cri) +} + +func frameSizeNV21(width, height int) uint { + yi := width * height + ci := yi + width*height/2 + return uint(ci) +} + +func frameSizeZ16(width, height int) uint { + expectedSize := 2 * (width * height) + return uint(expectedSize) +} + +func frameSizeMJPEG(width, height int) uint { + // MJPEG is a compressed format, so we don't know the size + panic("Not possible to get frame size with MJPEG format. Since it is a compressed format, so we don't know the size") +}