diff --git a/cmd/examples/capture/context.go b/cmd/examples/capture/context.go new file mode 100644 index 0000000..97c8708 --- /dev/null +++ b/cmd/examples/capture/context.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "os" + "os/signal" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// ContextForSignal returns a context object which is cancelled when a signal +// is received. It returns nil if no signal parameter is provided +func ContextForSignal(signals ...os.Signal) context.Context { + if len(signals) == 0 { + return nil + } + + ch := make(chan os.Signal, 1) + ctx, cancel := context.WithCancel(context.Background()) + + // Send message on channel when signal received + signal.Notify(ch, signals...) + + // When any signal received, call cancel + go func() { + <-ch + cancel() + }() + + // Return success + return ctx +} diff --git a/cmd/examples/capture/main.go b/cmd/examples/capture/main.go new file mode 100644 index 0000000..4dca63d --- /dev/null +++ b/cmd/examples/capture/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "image/jpeg" + "log" + "os" + "regexp" + "syscall" + + // Packages + media "github.com/mutablelogic/go-media" + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" +) + +var ( + reDeviceNamePath = regexp.MustCompile(`^([a-z][a-zA-Z0-9]+)\:(.*)$`) +) + +func main() { + if len(os.Args) != 2 { + log.Fatal("Usage: capture device:path") + } + + // Get the format associated with the input file + device := reDeviceNamePath.FindStringSubmatch(os.Args[1]) + if device == nil { + log.Fatal("Invalid device name, use device:path") + } + + // Create a media manager + manager, err := ffmpeg.NewManager(ffmpeg.OptLog(false, nil)) + if err != nil { + log.Fatal(err) + } + + // Find device + devices := manager.Formats(media.DEVICE, device[1]) + if len(devices) == 0 { + log.Fatalf("No devices found for %v", device[1]) + } + if len(devices) > 1 { + log.Fatalf("Multiple devices found: %q", devices) + } + + // Open device + media, err := manager.Open(device[2], devices[0]) + if err != nil { + log.Fatal(err) + } + defer media.Close() + + // Tmpdir + tmpdir, err := os.MkdirTemp("", "capture") + if err != nil { + log.Fatal(err) + } + + // Frame function + frameFunc := func(stream int, frame *ffmpeg.Frame) error { + w, err := os.Create(fmt.Sprintf("%v/frame-%v.jpg", tmpdir, frame.Ts())) + if err != nil { + return err + } + defer w.Close() + + image, err := frame.Image() + if err != nil { + return err + } + + if err := jpeg.Encode(w, image, nil); err != nil { + return err + } + + fmt.Println("Written", w.Name()) + + return nil + } + + // Map function + mapFunc := func(_ int, in *ffmpeg.Par) (*ffmpeg.Par, error) { + fmt.Println("Input", in) + return ffmpeg.VideoPar("yuv420p", in.WidthHeight(), in.FrameRate()), nil + } + + // Receive frames + if err := media.(*ffmpeg.Reader).Decode( + ContextForSignal(os.Interrupt, syscall.SIGQUIT), + mapFunc, + frameFunc, + ); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/examples/encode/context.go b/cmd/examples/encode/context.go new file mode 100644 index 0000000..97c8708 --- /dev/null +++ b/cmd/examples/encode/context.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "os" + "os/signal" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// ContextForSignal returns a context object which is cancelled when a signal +// is received. It returns nil if no signal parameter is provided +func ContextForSignal(signals ...os.Signal) context.Context { + if len(signals) == 0 { + return nil + } + + ch := make(chan os.Signal, 1) + ctx, cancel := context.WithCancel(context.Background()) + + // Send message on channel when signal received + signal.Notify(ch, signals...) + + // When any signal received, call cancel + go func() { + <-ch + cancel() + }() + + // Return success + return ctx +} diff --git a/cmd/examples/encode/main.go b/cmd/examples/encode/main.go index 306f215..6b3c5d6 100644 --- a/cmd/examples/encode/main.go +++ b/cmd/examples/encode/main.go @@ -1,10 +1,13 @@ package main import ( + "context" + "errors" "fmt" "io" "log" "os" + "syscall" // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" @@ -13,9 +16,14 @@ import ( // This example encodes an audio an video stream to a file func main() { + // Check we have a filename + if len(os.Args) != 2 { + log.Fatal("Usage: encode filename") + } + // Create a new file with an audio and video stream file, err := ffmpeg.Create(os.Args[1], - ffmpeg.OptStream(1, ffmpeg.VideoPar("yuv420p", "1280x720", 30)), + ffmpeg.OptStream(1, ffmpeg.VideoPar("yuv420p", "1280x720", 25, ffmpeg.NewMetadata("crf", 2))), ffmpeg.OptStream(2, ffmpeg.AudioPar("fltp", "mono", 22050)), ) if err != nil { @@ -39,10 +47,13 @@ func main() { } defer audio.Close() + // Bail out when we receive a signal + ctx := ContextForSignal(os.Interrupt, syscall.SIGQUIT) + // Write 90 seconds, passing video and audio frames to the encoder // and returning io.EOF when the duration is reached duration := float64(90) - err = file.Encode(func(stream int) (*ffmpeg.Frame, error) { + err = file.Encode(ctx, func(stream int) (*ffmpeg.Frame, error) { var frame *ffmpeg.Frame switch stream { case 1: @@ -51,12 +62,12 @@ func main() { frame = audio.Frame() } if frame != nil && frame.Ts() < duration { - fmt.Print(".") + fmt.Println(stream, frame.Ts()) return frame, nil } return nil, io.EOF }, nil) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { log.Fatal(err) } fmt.Print("\n") diff --git a/go.mod b/go.mod index 1d62c32..bf4d776 100755 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/mutablelogic/go-media -go 1.22 - -toolchain go1.22.4 +go 1.20 require ( github.com/alecthomas/kong v0.9.0 diff --git a/manager.go b/manager.go index 4c18db0..0380f0b 100644 --- a/manager.go +++ b/manager.go @@ -5,6 +5,8 @@ functions to determine capabilities and manage media files and devices. */ package media +import "io" + // Manager represents a manager for media formats and devices. // Create a new manager object using the NewManager function. // @@ -26,7 +28,7 @@ type Manager interface { // Open a media file or device for reading, from a path or url. // If a format is specified, then the format will be used to open // the file. Close the media object when done. - //Open(string, Format, ...string) (Media, error) + Open(string, Format, ...string) (Media, error) // Open a media stream for reading. If a format is // specified, then the format will be used to open the file. Close the @@ -47,11 +49,6 @@ type Manager interface { // of the caller to also close the writer when done. //Write(io.Writer, Format, []Metadata, ...Parameters) (Media, error) - // Return supported devices for a given format. - // Not all devices may be supported on all platforms or listed - // if the device does not support enumeration. - //Devices(Format) []Device - // Return audio parameters for encoding // ChannelLayout, SampleFormat, Samplerate //AudioParameters(string, string, int) (Parameters, error) @@ -68,15 +65,11 @@ type Manager interface { // Codec name, Profile name, Framerate (fps) and VideoParameters //VideoCodecParameters(string, string, float64, VideoParameters) (Parameters, error) - // Return supported input formats which match any filter, which can be - // a name, extension (with preceeding period) or mimetype. The MediaType - // can be NONE (for any) or combinations of DEVICE and STREAM. - //InputFormats(Type, ...string) []Format - - // Return supported output formats which match any filter, which can be - // a name, extension (with preceeding period) or mimetype. The MediaType - // can be NONE (for any) or combinations of DEVICE and STREAM. - //OutputFormats(Type, ...string) []Format + // Return supported input and output container formats which match any filter, + // which can be a name, extension (with preceeding period) or mimetype. The Type + // can be a combination of DEVICE, INPUT, OUTPUT or ANY to select the right kind of + // format + Formats(Type, ...string) []Format // Return all supported sample formats SampleFormats() []Metadata @@ -107,3 +100,26 @@ type Manager interface { // Log info messages with arguments Infof(string, ...any) } + +// A container format for a media file or stream +type Format interface { + // The type of the format, which can be combinations of + // INPUT, OUTPUT, DEVICE, AUDIO, VIDEO and SUBTITLE + Type() Type + + // The unique name that the format can be referenced as + Name() string + + // Description of the format + Description() string +} + +// A container format for a media file, reader, device or +// network stream +type Media interface { + io.Closer + + // The type of the format, which can be combinations of + // INPUT, OUTPUT, DEVICE + Type() Type +} diff --git a/pkg/ffmpeg/decoder.go b/pkg/ffmpeg/decoder.go index d26d7db..f709be5 100644 --- a/pkg/ffmpeg/decoder.go +++ b/pkg/ffmpeg/decoder.go @@ -8,6 +8,9 @@ import ( // Packages ff "github.com/mutablelogic/go-media/sys/ffmpeg61" + + // Namespace imports + . "github.com/djthorpe/go-errors" ) //////////////////////////////////////////////////////////////////////////////// @@ -35,7 +38,7 @@ func NewDecoder(stream *ff.AVStream, dest *Par, force bool) (*Decoder, error) { // Create a frame for decoder output - before resize/resample frame := ff.AVUtil_frame_alloc() if frame == nil { - return nil, errors.New("failed to allocate frame") + return nil, ErrInternalAppError.With("failed to allocate frame") } // Create a codec context for the decoder @@ -114,12 +117,12 @@ func (d *Decoder) Close() error { // correct timebase, etc set func (d *Decoder) decode(packet *ff.AVPacket, fn DecoderFrameFn) error { if fn == nil { - return errors.New("DecoderFrameFn is nil") + return ErrBadParameter.With("DecoderFrameFn is nil") } // Submit the packet to the decoder (nil packet will flush the decoder) if err := ff.AVCodec_send_packet(d.codec, packet); err != nil { - return err + return ErrInternalAppError.With("AVCodec_send_packet:", err) } // get all the available frames from the decoder @@ -136,7 +139,7 @@ func (d *Decoder) decode(packet *ff.AVPacket, fn DecoderFrameFn) error { // Finished decoding packet or EOF break } else if err != nil { - return err + return ErrInternalAppError.With("AVCodec_receive_frame:", err) } // Obtain the output frame. If a new frame is returned, it is diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go index 21aa27b..d6a0c26 100644 --- a/pkg/ffmpeg/encoder.go +++ b/pkg/ffmpeg/encoder.go @@ -72,10 +72,11 @@ func NewEncoder(ctx *ff.AVFormatContext, stream int, par *Par) (*Encoder, error) } // Create the stream - if streamctx := ff.AVFormat_new_stream(ctx, nil); streamctx == nil { + if streamctx := ff.AVFormat_new_stream(ctx, codec); streamctx == nil { ff.AVCodec_free_context(encoder.ctx) return nil, ErrInternalAppError.With("could not allocate stream") } else { + // Set stream identifier and timebase from parameters streamctx.SetId(stream) encoder.stream = streamctx } @@ -85,18 +86,40 @@ func NewEncoder(ctx *ff.AVFormatContext, stream int, par *Par) (*Encoder, error) encoder.ctx.SetFlags(encoder.ctx.Flags() | ff.AV_CODEC_FLAG_GLOBAL_HEADER) } + // Get the options + opts := par.newOpts() + if opts == nil { + ff.AVCodec_free_context(encoder.ctx) + return nil, ErrInternalAppError.With("could not allocate options dictionary") + } + defer ff.AVUtil_dict_free(opts) + // Open it - if err := ff.AVCodec_open(encoder.ctx, codec, nil); err != nil { + if err := ff.AVCodec_open(encoder.ctx, codec, opts); err != nil { ff.AVCodec_free_context(encoder.ctx) return nil, ErrInternalAppError.Withf("codec_open: %v", err) } + // If there are any non-consumed options, then error + var result error + for _, key := range ff.AVUtil_dict_keys(opts) { + result = errors.Join(result, ErrBadParameter.Withf("Stream %d: invalid codec option %q", stream, key)) + } + if result != nil { + ff.AVCodec_free_context(encoder.ctx) + return nil, result + } + // Copy parameters to stream if err := ff.AVCodec_parameters_from_context(encoder.stream.CodecPar(), encoder.ctx); err != nil { ff.AVCodec_free_context(encoder.ctx) return nil, err } + // Hint what timebase we want to encode at. This will change when writing the + // headers for the encoding process + encoder.stream.SetTimeBase(par.timebase) + // Create a packet packet := ff.AVCodec_packet_alloc() if packet == nil { @@ -164,6 +187,7 @@ func (e *Encoder) Encode(frame *Frame, fn EncoderPacketFn) error { // Return the codec parameters func (e *Encoder) Par() *Par { par := new(Par) + par.timebase = e.ctx.TimeBase() if err := ff.AVCodec_parameters_from_context(&par.AVCodecParameters, e.ctx); err != nil { return nil } else { @@ -171,14 +195,14 @@ func (e *Encoder) Par() *Par { } } -// Return the codec type +// Return the next expected timestamp after a frame has been encoded func (e *Encoder) nextPts(frame *Frame) int64 { next_pts := int64(0) switch e.ctx.Codec().Type() { case ff.AVMEDIA_TYPE_AUDIO: - next_pts = ff.AVUtil_rational_rescale_q(int64(frame.NumSamples()), ff.AVUtil_rational(1, frame.SampleRate()), e.stream.TimeBase()) + next_pts = ff.AVUtil_rational_rescale_q(int64(frame.NumSamples()), frame.TimeBase(), e.stream.TimeBase()) case ff.AVMEDIA_TYPE_VIDEO: - next_pts = ff.AVUtil_rational_rescale_q(1, ff.AVUtil_rational_invert(e.ctx.Framerate()), e.stream.TimeBase()) + next_pts = ff.AVUtil_rational_rescale_q(1, frame.TimeBase(), e.stream.TimeBase()) default: // Dunno what to do with subtitle and data streams yet fmt.Println("TODO: next_pts for subtitle and data streams") @@ -209,6 +233,8 @@ func (e *Encoder) encode(frame *Frame, fn EncoderPacketFn) error { // rescale output packet timestamp values from codec to stream timebase ff.AVCodec_packet_rescale_ts(e.packet, e.ctx.TimeBase(), e.stream.TimeBase()) + + // Set packet parameters e.packet.SetStreamIndex(e.stream.Index()) e.packet.SetTimeBase(e.stream.TimeBase()) diff --git a/pkg/ffmpeg/format.go b/pkg/ffmpeg/format.go new file mode 100644 index 0000000..257ebee --- /dev/null +++ b/pkg/ffmpeg/format.go @@ -0,0 +1,158 @@ +package ffmpeg + +import ( + "encoding/json" + "strings" + + // Packages + media "github.com/mutablelogic/go-media" + ff "github.com/mutablelogic/go-media/sys/ffmpeg61" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type metaFormat struct { + Type media.Type `json:"type"` + Name string `json:"name"` +} + +type Format struct { + metaFormat + Input *ff.AVInputFormat `json:"input,omitempty"` + Output *ff.AVOutputFormat `json:"output,omitempty"` + Devices []*Device `json:"devices,omitempty"` +} + +type Device struct { + metaDevice +} + +type metaDevice struct { + Name string `json:"name" writer:",wrap,width:50"` + Description string `json:"description" writer:",wrap,width:40"` + Default bool `json:"default,omitempty"` +} + +var _ media.Format = &Format{} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func newInputFormats(demuxer *ff.AVInputFormat, t media.Type) []media.Format { + names := strings.Split(demuxer.Name(), ",") + result := make([]media.Format, 0, len(names)) + + // Populate devices by name + for _, name := range names { + result = append(result, &Format{ + metaFormat: metaFormat{Type: t, Name: name}, + Input: demuxer, + }) + } + + if !t.Is(media.DEVICE) { + return result + } + + // Get devices + list, err := ff.AVDevice_list_input_sources(demuxer, "", nil) + if err != nil { + // Bail out if we can't get the list of devices + return result + } + defer ff.AVDevice_free_list_devices(list) + + // Make device list + devices := make([]*Device, 0, list.NumDevices()) + for i, device := range list.Devices() { + devices = append(devices, &Device{ + metaDevice{ + Name: device.Name(), + Description: device.Description(), + Default: list.Default() == i, + }, + }) + } + + // Append to result + for _, format := range result { + format.(*Format).Devices = devices + } + + // Return result + return result +} + +func newOutputFormats(muxer *ff.AVOutputFormat, t media.Type) []media.Format { + names := strings.Split(muxer.Name(), ",") + result := make([]media.Format, 0, len(names)) + for _, name := range names { + result = append(result, &Format{ + metaFormat: metaFormat{Type: t, Name: name}, + Output: muxer, + }) + } + + if !t.Is(media.DEVICE) { + return result + } + + // Get devices + list, err := ff.AVDevice_list_output_sinks(muxer, "", nil) + if err != nil { + // Bail out if we can't get the list of devices + return result + } + defer ff.AVDevice_free_list_devices(list) + + // Make device list + devices := make([]*Device, 0, list.NumDevices()) + for i, device := range list.Devices() { + devices = append(devices, &Device{ + metaDevice{ + Name: device.Name(), + Description: device.Description(), + Default: list.Default() == i, + }, + }) + } + + // Append to result + for _, format := range result { + format.(*Format).Devices = devices + } + + // Return result + return result +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (f *Format) String() string { + data, _ := json.MarshalIndent(f, "", " ") + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (f *Format) Type() media.Type { + return f.metaFormat.Type +} + +func (f *Format) Name() string { + return f.metaFormat.Name +} + +func (f *Format) Description() string { + switch { + case f.Input != nil: + return f.Input.LongName() + case f.Output != nil: + return f.Output.LongName() + default: + return f.metaFormat.Name + } +} diff --git a/pkg/ffmpeg/frame.go b/pkg/ffmpeg/frame.go index e843614..365b67b 100644 --- a/pkg/ffmpeg/frame.go +++ b/pkg/ffmpeg/frame.go @@ -3,6 +3,7 @@ package ffmpeg import ( "encoding/json" "errors" + "fmt" // Packages media "github.com/mutablelogic/go-media" @@ -53,7 +54,7 @@ func NewFrame(par *Par) (*Frame, error) { frame.SetWidth(par.Width()) frame.SetHeight(par.Height()) frame.SetSampleAspectRatio(par.SampleAspectRatio()) - frame.SetTimeBase(ff.AVUtil_rational_invert(par.Framerate())) + frame.SetTimeBase(par.timebase) // Also sets framerate default: ff.AVUtil_frame_free(frame) return nil, errors.New("invalid codec type") @@ -88,6 +89,11 @@ func (frame *Frame) AllocateBuffers() error { return ff.AVUtil_frame_get_buffer((*ff.AVFrame)(frame), false) } +// Return true if the frame has allocated buffers +func (frame *Frame) IsAllocated() bool { + return ff.AVUtil_frame_is_allocated((*ff.AVFrame)(frame)) +} + // Make the frame writable func (frame *Frame) MakeWritable() error { return ff.AVUtil_frame_make_writable((*ff.AVFrame)(frame)) @@ -99,6 +105,7 @@ func (frame *Frame) Copy() (*Frame, error) { if copy == nil { return nil, errors.New("failed to allocate frame") } + switch frame.Type() { case media.AUDIO: copy.SetSampleFormat(frame.SampleFormat()) @@ -114,18 +121,21 @@ func (frame *Frame) Copy() (*Frame, error) { ff.AVUtil_frame_free(copy) return nil, errors.New("invalid codec type") } - if err := ff.AVUtil_frame_get_buffer(copy, false); err != nil { - ff.AVUtil_frame_free(copy) - return nil, err - } - if err := ff.AVUtil_frame_copy(copy, (*ff.AVFrame)(frame)); err != nil { - ff.AVUtil_frame_free(copy) - return nil, err + if frame.IsAllocated() { + if err := ff.AVUtil_frame_get_buffer(copy, false); err != nil { + ff.AVUtil_frame_free(copy) + return nil, fmt.Errorf("AVUtil_frame_get_buffer: %w", err) + } + if err := ff.AVUtil_frame_copy(copy, (*ff.AVFrame)(frame)); err != nil { + ff.AVUtil_frame_free(copy) + return nil, fmt.Errorf("AVUtil_frame_copy: %w", err) + } } if err := ff.AVUtil_frame_copy_props(copy, (*ff.AVFrame)(frame)); err != nil { ff.AVUtil_frame_free(copy) - return nil, err + return nil, fmt.Errorf("AVUtil_frame_copy_props: %w", err) } + return (*Frame)(copy), nil } diff --git a/pkg/ffmpeg/frame_test.go b/pkg/ffmpeg/frame_test.go index fc684ab..894fae3 100644 --- a/pkg/ffmpeg/frame_test.go +++ b/pkg/ffmpeg/frame_test.go @@ -48,3 +48,24 @@ func Test_frame_003(t *testing.T) { assert.Equal(720, frame.Height()) t.Log(frame) } + +func Test_frame_004(t *testing.T) { + assert := assert.New(t) + + frame, err := ffmpeg.NewFrame(ffmpeg.VideoPar("rgba", "1280x720", 25)) + if !assert.NoError(err) { + t.FailNow() + } + defer frame.Close() + + copy, err := frame.Copy() + if !assert.NoError(err) { + t.FailNow() + } + defer copy.Close() + + assert.Equal(copy.Type(), frame.Type()) + assert.Equal(copy.PixelFormat(), frame.PixelFormat()) + assert.Equal(copy.Width(), frame.Width()) + assert.Equal(copy.Height(), frame.Height()) +} diff --git a/pkg/ffmpeg/manager.go b/pkg/ffmpeg/manager.go index 328d5cb..8f5e0eb 100644 --- a/pkg/ffmpeg/manager.go +++ b/pkg/ffmpeg/manager.go @@ -2,11 +2,15 @@ package ffmpeg import ( "slices" + "strings" // Packages media "github.com/mutablelogic/go-media" version "github.com/mutablelogic/go-media/pkg/version" ff "github.com/mutablelogic/go-media/sys/ffmpeg61" + + // Namespace imports + . "github.com/djthorpe/go-errors" ) /////////////////////////////////////////////////////////////////////////////// @@ -69,9 +73,6 @@ func NewManager(opt ...Opt) (*Manager, error) { return manager, nil } -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - // Open a media file or device for reading, from a path or url. // If a format is specified, then the format will be used to open // the file. You can add additional options to the open call as @@ -97,6 +98,212 @@ func (manager *Manager) NewReader(r io.Reader, format media.Format, opts ...stri } */ +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - READER + +func (manager *Manager) Open(url string, format media.Format, opts ...string) (media.Media, error) { + o := append([]Opt{}, manager.opts[:]...) + if format != nil { + if format_, ok := format.(*Format); ok && format_.Input != nil { + o = append(o, optInputFormat(format_)) + } else { + return nil, ErrBadParameter.With("invalid input format") + } + } + if len(opts) > 0 { + o = append(o, OptInputOpt(opts...)) + } + return Open(url, o...) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - FORMATS + +func (manager *Manager) Formats(t media.Type, name ...string) []media.Format { + // Create filters + matchesInputFilter := func(demuxer *ff.AVInputFormat, filter string) bool { + if strings.HasPrefix(filter, ".") { + // By extension + ext := strings.Split(demuxer.Extensions(), ",") + for _, ext := range ext { + if filter == "."+ext { + return true + } + } + } else if strings.Contains(filter, "/") { + // By mimetype + if slices.Contains(strings.Split(demuxer.MimeTypes(), ","), filter) { + return true + } + } else { + // By name + if slices.Contains(strings.Split(demuxer.Name(), ","), filter) { + return true + } + } + return false + } + matchesOutputFilter := func(muxer *ff.AVOutputFormat, filter string) bool { + if strings.HasPrefix(filter, ".") { + // By extension + ext := strings.Split(muxer.Extensions(), ",") + for _, ext := range ext { + if filter == "."+ext { + return true + } + } + } else if strings.Contains(filter, "/") { + // By mimetype + if slices.Contains(strings.Split(muxer.MimeTypes(), ","), filter) { + return true + } + } else { + // By name + if slices.Contains(strings.Split(muxer.Name(), ","), filter) { + return true + } + } + return false + } + matchesInputFormat := func(demuxer *ff.AVInputFormat, t media.Type, filter ...string) bool { + // Check for INPUT + if !t.Is(media.INPUT) && !t.Is(media.ANY) { + return false + } + if t.Is(media.DEVICE) { + return false + } + if len(filter) == 0 { + return true + } + for _, filter := range filter { + if matchesInputFilter(demuxer, filter) { + return true + } + } + return false + } + matchesOutputFormat := func(muxer *ff.AVOutputFormat, t media.Type, filter ...string) bool { + // Check for OUTPUT + if !t.Is(media.OUTPUT) && !t.Is(media.ANY) { + return false + } + if t.Is(media.DEVICE) { + return false + } + if len(filter) == 0 { + return true + } + for _, filter := range filter { + if matchesOutputFilter(muxer, filter) { + return true + } + } + return false + } + matchesInputDevice := func(demuxer *ff.AVInputFormat, filter ...string) bool { + if len(filter) == 0 { + return true + } + for _, filter := range filter { + if demuxer.Name() == filter { + return true + } + } + return false + } + matchesOutputDevice := func(muxer *ff.AVOutputFormat, filter ...string) bool { + if len(filter) == 0 { + return true + } + for _, filter := range filter { + if muxer.Name() == filter { + return true + } + } + return false + } + + // Iterate over all input formats + var opaque uintptr + result := []media.Format{} + for { + demuxer := ff.AVFormat_demuxer_iterate(&opaque) + if demuxer == nil { + break + } + if matchesInputFormat(demuxer, t, name...) { + result = append(result, newInputFormats(demuxer, media.INPUT)...) + } + } + + // Iterate over all output formats + var opaque2 uintptr + for { + muxer := ff.AVFormat_muxer_iterate(&opaque2) + if muxer == nil { + break + } + if matchesOutputFormat(muxer, t, name...) { + result = append(result, newOutputFormats(muxer, media.OUTPUT)...) + } + } + + // Return if DEVICE is not requested + if !t.Is(media.DEVICE) && !t.Is(media.ANY) { + return result + } + + // Iterate over all device inputs + audio_input := ff.AVDevice_input_audio_device_first() + for { + if audio_input == nil { + break + } + if matchesInputDevice(audio_input, name...) { + result = append(result, newInputFormats(audio_input, media.INPUT|media.AUDIO|media.DEVICE)...) + } + audio_input = ff.AVDevice_input_audio_device_next(audio_input) + } + + video_input := ff.AVDevice_input_video_device_first() + for { + if video_input == nil { + break + } + if matchesInputDevice(video_input, name...) { + result = append(result, newInputFormats(video_input, media.INPUT|media.VIDEO|media.DEVICE)...) + } + video_input = ff.AVDevice_input_video_device_next(video_input) + } + + // Iterate over all device outputs + audio_output := ff.AVDevice_output_audio_device_first() + for { + if audio_output == nil { + break + } + if matchesOutputDevice(audio_output, name...) { + result = append(result, newOutputFormats(audio_output, media.OUTPUT|media.AUDIO|media.DEVICE)...) + } + audio_output = ff.AVDevice_output_audio_device_next(audio_output) + } + + video_output := ff.AVDevice_output_video_device_first() + for { + if video_output == nil { + break + } + if matchesOutputDevice(video_output, name...) { + result = append(result, newOutputFormats(video_output, media.OUTPUT|media.VIDEO|media.DEVICE)...) + } + video_output = ff.AVDevice_output_video_device_next(video_output) + } + + // Return formats + return result +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS - CODECS, PIXEL FORMATS, SAMPLE FORMATS AND CHANNEL // LAYOUTS diff --git a/pkg/ffmpeg/manager_test.go b/pkg/ffmpeg/manager_test.go index 9a71458..c0e34dd 100644 --- a/pkg/ffmpeg/manager_test.go +++ b/pkg/ffmpeg/manager_test.go @@ -4,6 +4,7 @@ import ( "testing" // Packages + media "github.com/mutablelogic/go-media" ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" ) @@ -39,3 +40,34 @@ func Test_manager_002(t *testing.T) { t.Log(v) } } + +func Test_manager_003(t *testing.T) { + assert := assert.New(t) + + // Create a manager + manager, err := ffmpeg.NewManager(ffmpeg.OptLog(true, func(v string) { + t.Log(v) + })) + if !assert.NoError(err) { + t.FailNow() + } + + for _, v := range manager.Formats(media.ANY) { + t.Log(v) + } +} + +func Test_manager_004(t *testing.T) { + assert := assert.New(t) + + // Create a manager + manager, err := ffmpeg.NewManager(ffmpeg.OptLog(true, func(v string) { + t.Log(v) + })) + if !assert.NoError(err) { + t.FailNow() + } + for _, format := range manager.Formats(media.DEVICE) { + t.Logf("%v", format) + } +} diff --git a/pkg/ffmpeg/opts.go b/pkg/ffmpeg/opts.go index 36555eb..60eb761 100644 --- a/pkg/ffmpeg/opts.go +++ b/pkg/ffmpeg/opts.go @@ -2,6 +2,7 @@ package ffmpeg import ( // Package imports + media "github.com/mutablelogic/go-media" ffmpeg "github.com/mutablelogic/go-media/sys/ffmpeg61" // Namespace imports @@ -31,6 +32,7 @@ type opts struct { metadata []*Metadata // Reader options + t media.Type iformat *ffmpeg.AVInputFormat opts []string // These are key=value pairs } @@ -86,6 +88,19 @@ func OptInputFormat(name string) Opt { } } +// Input format from ff.AVInputFormat +func optInputFormat(format *Format) Opt { + return func(o *opts) error { + if format != nil && format.Input != nil { + o.iformat = format.Input + o.t = format.Type() + } else { + return ErrBadParameter.With("invalid input format") + } + return nil + } +} + // Input format options func OptInputOpt(opt ...string) Opt { return func(o *opts) error { diff --git a/pkg/ffmpeg/par.go b/pkg/ffmpeg/par.go index 3c08baf..1812ac3 100644 --- a/pkg/ffmpeg/par.go +++ b/pkg/ffmpeg/par.go @@ -18,14 +18,25 @@ import ( type Par struct { ff.AVCodecParameters + opts []media.Metadata + timebase ff.AVRational +} + +type jsonPar struct { + ff.AVCodecParameters + Timebase ff.AVRational `json:"timebase"` + Opts []media.Metadata `json:"options"` } /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -func NewAudioPar(samplefmt string, channellayout string, samplerate int) (*Par, error) { +// Create new audio parameters with sample format, channel layout and sample rate +// plus any additional options which is used for creating a stream +func NewAudioPar(samplefmt string, channellayout string, samplerate int, opts ...media.Metadata) (*Par, error) { par := new(Par) par.SetCodecType(ff.AVMEDIA_TYPE_AUDIO) + par.opts = opts // Sample Format if samplefmt_ := ff.AVUtil_get_sample_fmt(samplefmt); samplefmt_ == ff.AV_SAMPLE_FMT_NONE { @@ -53,9 +64,12 @@ func NewAudioPar(samplefmt string, channellayout string, samplerate int) (*Par, return par, nil } -func NewVideoPar(pixfmt string, size string, framerate float64) (*Par, error) { +// Create new video parameters with pixel format, frame size, framerate +// plus any additional options which is used for creating a stream +func NewVideoPar(pixfmt string, size string, framerate float64, opts ...media.Metadata) (*Par, error) { par := new(Par) par.SetCodecType(ff.AVMEDIA_TYPE_VIDEO) + par.opts = opts // Pixel Format if pixfmt_ := ff.AVUtil_get_pix_fmt(pixfmt); pixfmt_ == ff.AV_PIX_FMT_NONE { @@ -72,45 +86,32 @@ func NewVideoPar(pixfmt string, size string, framerate float64) (*Par, error) { par.SetHeight(h) } - // Frame rate + // Frame rate and timebase if framerate < 0 { return nil, ErrBadParameter.Withf("negative framerate %v", framerate) - } else { - par.SetFramerate(ff.AVUtil_rational_d2q(framerate, 1<<24)) + } else if framerate > 0 { + par.timebase = ff.AVUtil_rational_invert(ff.AVUtil_rational_d2q(framerate, 1<<24)) } // Set default sample aspect ratio par.SetSampleAspectRatio(ff.AVUtil_rational(1, 1)) - /* TODO - c->gop_size = 12; // emit one intra frame every twelve frames at most - c->pix_fmt = STREAM_PIX_FMT; - if (c->codec_id == AV_CODEC_ID_MPEG2VIDEO) { - // just for testing, we also add B-frames - c->max_b_frames = 2; - } - if (c->codec_id == AV_CODEC_ID_MPEG1VIDEO) { - // Needed to avoid using macroblocks in which some coeffs overflow. - // This does not happen with normal video, it just happens here as - // the motion of the chroma plane does not match the luma plane. - c->mb_decision = 2; - } - */ - // Return success return par, nil } -func AudioPar(samplefmt string, channellayout string, samplerate int) *Par { - if par, err := NewAudioPar(samplefmt, channellayout, samplerate); err != nil { +// Create audio parameters. If there is an error, then this function will panic +func AudioPar(samplefmt string, channellayout string, samplerate int, opts ...media.Metadata) *Par { + if par, err := NewAudioPar(samplefmt, channellayout, samplerate, opts...); err != nil { panic(err) } else { return par } } -func VideoPar(pixfmt string, size string, framerate float64) *Par { - if par, err := NewVideoPar(pixfmt, size, framerate); err != nil { +// Create video parameters. If there is an error, then this function will panic +func VideoPar(pixfmt string, size string, framerate float64, opts ...media.Metadata) *Par { + if par, err := NewVideoPar(pixfmt, size, framerate, opts...); err != nil { panic(err) } else { return par @@ -121,12 +122,20 @@ func VideoPar(pixfmt string, size string, framerate float64) *Par { // STRINGIFY func (ctx *Par) MarshalJSON() ([]byte, error) { - return json.Marshal(ctx.AVCodecParameters) + return json.Marshal(jsonPar{ + AVCodecParameters: ctx.AVCodecParameters, + Timebase: ctx.timebase, + Opts: ctx.opts, + }) } func (ctx *Par) String() string { - data, _ := json.MarshalIndent(ctx, "", " ") - return string(data) + data, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return err.Error() + } else { + return string(data) + } } /////////////////////////////////////////////////////////////////////////////// @@ -152,7 +161,10 @@ func (ctx *Par) WidthHeight() string { } func (ctx *Par) FrameRate() float64 { - return ff.AVUtil_rational_q2d(ctx.Framerate()) + if ctx.timebase.Num() == 0 || ctx.timebase.Den() == 0 { + return 0 + } + return ff.AVUtil_rational_q2d(ff.AVUtil_rational_invert(ctx.timebase)) } func (ctx *Par) ValidateFromCodec(codec *ff.AVCodec) error { @@ -178,9 +190,23 @@ func (ctx *Par) CopyToCodecContext(codec *ff.AVCodecContext) error { /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS +// Return options as a dictionary, which needs to be freed after use +// by the caller method +func (ctx *Par) newOpts() *ff.AVDictionary { + dict := ff.AVUtil_dict_alloc() + for _, opt := range ctx.opts { + if err := ff.AVUtil_dict_set(dict, opt.Key(), opt.Value(), ff.AV_DICT_APPEND); err != nil { + ff.AVUtil_dict_free(dict) + return nil + } + } + return dict +} + func (ctx *Par) copyAudioCodec(codec *ff.AVCodecContext) error { codec.SetSampleFormat(ctx.SampleFormat()) codec.SetSampleRate(ctx.Samplerate()) + codec.SetTimeBase(ff.AVUtil_rational(1, ctx.Samplerate())) if err := codec.SetChannelLayout(ctx.ChannelLayout()); err != nil { return err } @@ -250,8 +276,7 @@ func (ctx *Par) copyVideoCodec(codec *ff.AVCodecContext) error { codec.SetWidth(ctx.Width()) codec.SetHeight(ctx.Height()) codec.SetSampleAspectRatio(ctx.SampleAspectRatio()) - codec.SetFramerate(ctx.Framerate()) - codec.SetTimeBase(ff.AVUtil_rational_invert(ctx.Framerate())) + codec.SetTimeBase(ctx.timebase) return nil } @@ -265,9 +290,9 @@ func (ctx *Par) validateVideoCodec(codec *ff.AVCodec) error { ctx.SetPixelFormat(pixelformats[0]) } } - if ctx.Framerate().Num() == 0 || ctx.Framerate().Den() == 0 { + if ctx.timebase.Num() == 0 || ctx.timebase.Den() == 0 { if len(framerates) > 0 { - ctx.SetFramerate(framerates[0]) + ctx.timebase = ff.AVUtil_rational_invert(framerates[0]) } } @@ -286,18 +311,18 @@ func (ctx *Par) validateVideoCodec(codec *ff.AVCodec) error { if ctx.SampleAspectRatio().Num() == 0 || ctx.SampleAspectRatio().Den() == 0 { ctx.SetSampleAspectRatio(ff.AVUtil_rational(1, 1)) } - if ctx.Framerate().Num() == 0 || ctx.Framerate().Den() == 0 { + if ctx.timebase.Num() == 0 || ctx.timebase.Den() == 0 { return ErrBadParameter.With("framerate not set") } else if len(framerates) > 0 { valid := false for _, fr := range framerates { - if ff.AVUtil_rational_equal(fr, ctx.Framerate()) { + if ff.AVUtil_rational_equal(fr, ff.AVUtil_rational_invert(ctx.timebase)) { valid = true break } } if !valid { - return ErrBadParameter.Withf("unsupported framerate %v", ctx.Framerate()) + return ErrBadParameter.Withf("unsupported framerate %v", ff.AVUtil_rational_invert(ctx.timebase)) } } diff --git a/pkg/ffmpeg/reader.go b/pkg/ffmpeg/reader.go index ffed98e..55f5042 100644 --- a/pkg/ffmpeg/reader.go +++ b/pkg/ffmpeg/reader.go @@ -4,11 +4,10 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "slices" "strings" - "sync" + "syscall" "time" // Packages @@ -24,6 +23,7 @@ import ( // Media reader which reads from a URL, file path or device type Reader struct { + t media.Type input *ff.AVFormatContext avio *ff.AVIOContextEx force bool @@ -124,8 +124,9 @@ func (r *Reader) open(options *opts) (*Reader, error) { return nil, err } - // Set force flag + // Set force flag and type r.force = options.force + r.t = options.t | media.INPUT // Return success return r, nil @@ -166,6 +167,11 @@ func (r *Reader) String() string { //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS +// Return the media type +func (r *Reader) Type() media.Type { + return r.t +} + // Return the duration of the media stream, returns zero if unknown func (r *Reader) Duration() time.Duration { duration := r.input.Duration() @@ -251,6 +257,7 @@ func (r *Reader) Decode(ctx context.Context, mapfn DecoderMapFunc, decodefn Deco // As per the decode method, the map function is called for each stream and should return the // parameters for the destination. If the map function returns nil for a stream, then // the stream is ignored. +/* func (r *Reader) Transcode(ctx context.Context, w io.Writer, mapfn DecoderMapFunc, opt ...Opt) error { // Map streams to decoders decoders, err := r.mapStreams(mapfn) @@ -309,7 +316,7 @@ func (r *Reader) Transcode(ctx context.Context, w io.Writer, mapfn DecoderMapFun // Return any errors return result } - +*/ //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS - DECODE @@ -346,6 +353,7 @@ func (r *Reader) mapStreams(fn DecoderMapFunc) (decoderMap, error) { // Get decoder parameters and map to a decoder par, err := fn(stream_index, &Par{ AVCodecParameters: *stream.CodecPar(), + timebase: stream.TimeBase(), }) if err != nil { result = errors.Join(result, err) @@ -391,8 +399,10 @@ FOR_LOOP: default: if err := ff.AVFormat_read_frame(r.input, packet); errors.Is(err, io.EOF) { break FOR_LOOP + } else if errors.Is(err, syscall.EAGAIN) { + continue FOR_LOOP } else if err != nil { - return err + return ErrInternalAppError.With("AVFormat_read_frame: ", err) } stream_index := packet.StreamIndex() if decoder := decoders[stream_index]; decoder != nil { diff --git a/pkg/ffmpeg/resampler.go b/pkg/ffmpeg/resampler.go index 8a2f71a..b88ac47 100644 --- a/pkg/ffmpeg/resampler.go +++ b/pkg/ffmpeg/resampler.go @@ -112,11 +112,15 @@ func (r *resampler) Frame(src *Frame) (*Frame, error) { sample_fmt := r.dest.SampleFormat() sample_rate := r.dest.SampleRate() sample_ch := r.dest.ChannelLayout() + sample_tb := r.dest.TimeBase() + sample_pts := r.dest.Pts() r.dest.Unref() (*ff.AVFrame)(r.dest).SetSampleFormat(sample_fmt) (*ff.AVFrame)(r.dest).SetSampleRate(sample_rate) (*ff.AVFrame)(r.dest).SetChannelLayout(sample_ch) (*ff.AVFrame)(r.dest).SetNumSamples(num_samples) + (*ff.AVFrame)(r.dest).SetTimeBase(sample_tb) + (*ff.AVFrame)(r.dest).SetPts(sample_pts) if err := r.dest.AllocateBuffers(); err != nil { ff.SWResample_free(r.ctx) return nil, err @@ -130,6 +134,11 @@ func (r *resampler) Frame(src *Frame) (*Frame, error) { return nil, nil } + // Set the pts + if src != nil && src.Pts() != ff.AV_NOPTS_VALUE { + r.dest.SetPts(ff.AVUtil_rational_rescale_q(src.Pts(), src.TimeBase(), r.dest.TimeBase())) + } + // Return the destination frame or nil return r.dest, nil } diff --git a/pkg/ffmpeg/resampler_test.go b/pkg/ffmpeg/resampler_test.go index 5e4483e..31e8d4f 100644 --- a/pkg/ffmpeg/resampler_test.go +++ b/pkg/ffmpeg/resampler_test.go @@ -49,9 +49,9 @@ func Test_resampler_001(t *testing.T) { if !assert.NoError(err) { t.FailNow() } - t.Log(" =>", dest) if dest == nil { break } + t.Log(" =>", dest) } } diff --git a/pkg/ffmpeg/writer.go b/pkg/ffmpeg/writer.go index 60c8fa4..c59b785 100644 --- a/pkg/ffmpeg/writer.go +++ b/pkg/ffmpeg/writer.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "context" "encoding/json" "errors" "fmt" @@ -240,7 +241,7 @@ func (w *Writer) Stream(stream int) *Encoder { // the frame. If the callback function returns io.EOF then the encoding for // that encoder is stopped after flushing. If the second callback is nil, // then packets are written to the output. -func (w *Writer) Encode(in EncoderFrameFn, out EncoderPacketFn) error { +func (w *Writer) Encode(ctx context.Context, in EncoderFrameFn, out EncoderPacketFn) error { if in == nil { return ErrBadParameter.With("nil in or out") } @@ -265,53 +266,35 @@ func (w *Writer) Encode(in EncoderFrameFn, out EncoderPacketFn) error { encoder.next_pts = 0 } - // Continue until all encoders have returned io.EOF and have been flushed + // Continue until all encoders have returned io.EOF (or context cancelled) + // and have been flushed for { // No more encoding to do if len(encoders) == 0 { break } - // Find encoder with the lowest timestamp, based on next_pts and timebase - next_stream := -1 - var next_encoder *Encoder - for stream, encoder := range encoders { - if next_encoder == nil || compareNextPts(encoder, next_encoder) < 0 { - next_encoder = encoder - next_stream = stream + // Mark as EOF if context is done + select { + case <-ctx.Done(): + // Mark all encoders as EOF to flush them + for _, encoder := range encoders { + encoder.eof = true } - } - - var frame *Frame - var err error - - // Receive a frame if not EOF - if !next_encoder.eof { - frame, err = in(next_stream) - if errors.Is(err, io.EOF) { - next_encoder.eof = true - } else if err != nil { - return fmt.Errorf("stream %v: %w", next_stream, err) + // Perform the encode + if err := encode(in, out, encoders); err != nil { + return err + } + default: + // Perform the encode + if err := encode(in, out, encoders); err != nil { + return err } } - - // Send a frame for encoding - if err := next_encoder.Encode(frame, out); err != nil { - return fmt.Errorf("stream %v: %w", next_stream, err) - } - - // If eof then delete the encoder - if next_encoder.eof { - delete(encoders, next_stream) - continue - } - - // Calculate the next PTS - next_encoder.next_pts = next_encoder.next_pts + next_encoder.nextPts(frame) } - // Return success - return nil + // Return error from context + return ctx.Err() } // Write a packet to the output. If you intercept the packets in the @@ -325,6 +308,49 @@ func compareNextPts(a, b *Encoder) int { return ff.AVUtil_compare_ts(a.next_pts, a.stream.TimeBase(), b.next_pts, b.stream.TimeBase()) } +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS - Encoding + +// Find encoder with the lowest timestamp, based on next_pts and timebase +// and send to the the EncoderFrameFn +func encode(in EncoderFrameFn, out EncoderPacketFn, encoders map[int]*Encoder) error { + next_stream := -1 + var next_encoder *Encoder + for stream, encoder := range encoders { + if next_encoder == nil || compareNextPts(encoder, next_encoder) < 0 { + next_encoder = encoder + next_stream = stream + } + } + + // Receive a frame if not EOF + var frame *Frame + var err error + if !next_encoder.eof { + frame, err = in(next_stream) + if errors.Is(err, io.EOF) { + next_encoder.eof = true + } else if err != nil { + return fmt.Errorf("stream %v: %w", next_stream, err) + } + } + + // Send a frame for encoding + if err := next_encoder.Encode(frame, out); err != nil { + return fmt.Errorf("stream %v: %w", next_stream, err) + } + + // If eof then delete the encoder + if next_encoder.eof { + delete(encoders, next_stream) + return nil + } + + // Calculate the next PTS + next_encoder.next_pts = next_encoder.next_pts + next_encoder.nextPts(frame) + return nil +} + //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS - Writer diff --git a/pkg/ffmpeg/writer_test.go b/pkg/ffmpeg/writer_test.go index 671598a..d6e4b3e 100644 --- a/pkg/ffmpeg/writer_test.go +++ b/pkg/ffmpeg/writer_test.go @@ -1,6 +1,7 @@ package ffmpeg_test import ( + "context" "io" "os" "testing" @@ -41,7 +42,7 @@ func Test_writer_001(t *testing.T) { // Write 1 min of frames duration := float64(60) - assert.NoError(writer.Encode(func(stream int) (*ffmpeg.Frame, error) { + assert.NoError(writer.Encode(context.Background(), func(stream int) (*ffmpeg.Frame, error) { frame := audio.Frame() if frame.Ts() >= duration { return nil, io.EOF @@ -87,7 +88,7 @@ func Test_writer_002(t *testing.T) { // Write 15 mins of frames duration := float64(15 * 60) - assert.NoError(writer.Encode(func(stream int) (*ffmpeg.Frame, error) { + assert.NoError(writer.Encode(context.Background(), func(stream int) (*ffmpeg.Frame, error) { frame := audio.Frame() if frame.Ts() >= duration { return nil, io.EOF @@ -133,16 +134,21 @@ func Test_writer_003(t *testing.T) { // Write 1 min of frames duration := float64(60) - assert.NoError(writer.Encode(func(stream int) (*ffmpeg.Frame, error) { + assert.NoError(writer.Encode(context.Background(), func(stream int) (*ffmpeg.Frame, error) { frame := video.Frame() if frame.Ts() >= duration { return nil, io.EOF - } else { - t.Log("Frame", stream, "=>", frame.Ts()) - return frame, nil } + if !assert.NotEqual(ffmpeg.TS_UNDEFINED, frame.Ts()) { + t.FailNow() + } + t.Log("Frame", stream, "=>", frame.Ts()) + return frame, nil }, func(packet *ffmpeg.Packet) error { if packet != nil { + if !assert.NotEqual(ffmpeg.TS_UNDEFINED, packet.Ts()) { + t.FailNow() + } t.Log("Packet", packet.Ts()) } return writer.Write(packet) @@ -186,7 +192,7 @@ func Test_writer_004(t *testing.T) { // Write 10 secs of frames duration := float64(10) - assert.NoError(writer.Encode(func(stream int) (*ffmpeg.Frame, error) { + assert.NoError(writer.Encode(context.Background(), func(stream int) (*ffmpeg.Frame, error) { var frame *ffmpeg.Frame switch stream { case 1: diff --git a/pkg/generator/ebu.go b/pkg/generator/ebu.go index 70a4e5d..53e9d63 100644 --- a/pkg/generator/ebu.go +++ b/pkg/generator/ebu.go @@ -14,7 +14,6 @@ import ( media "github.com/mutablelogic/go-media" fonts "github.com/mutablelogic/go-media/etc/fonts" ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" - ff "github.com/mutablelogic/go-media/sys/ffmpeg61" ) //////////////////////////////////////////////////////////////////////////// @@ -37,7 +36,7 @@ func init() { draw2d.SetFontCache(fonts.NewFontCache()) } -// Create a new video generator which generates the EBU Test Card +// Create a new video generator which generates the EBU Colour Bars func NewEBU(par *ffmpeg.Par) (*ebu, error) { ebu := new(ebu) @@ -45,7 +44,7 @@ func NewEBU(par *ffmpeg.Par) (*ebu, error) { if par.Type() != media.VIDEO { return nil, errors.New("invalid codec type") } - framerate := ff.AVUtil_rational_q2d(par.Framerate()) + framerate := par.FrameRate() if framerate <= 0 { return nil, errors.New("invalid framerate") } diff --git a/pkg/generator/yuv420p.go b/pkg/generator/yuv420p.go index abd8fae..82febfd 100644 --- a/pkg/generator/yuv420p.go +++ b/pkg/generator/yuv420p.go @@ -33,8 +33,7 @@ func NewYUV420P(par *ffmpeg.Par) (*yuv420p, error) { } else if par.PixelFormat() != ff.AV_PIX_FMT_YUV420P { return nil, errors.New("invalid pixel format, only yuv420p is supported") } - framerate := ff.AVUtil_rational_q2d(par.Framerate()) - if framerate <= 0 { + if framerate := par.FrameRate(); framerate <= 0 { return nil, errors.New("invalid framerate") } @@ -76,15 +75,11 @@ func (yuv420p *yuv420p) String() string { // Return the first and subsequent frames of raw video data func (yuv420p *yuv420p) Frame() *ffmpeg.Frame { - // Make a writable copy if the frame is not writable - if err := yuv420p.frame.MakeWritable(); err != nil { - return nil - } - // Set the Pts if yuv420p.frame.Pts() == ffmpeg.PTS_UNDEFINED { yuv420p.frame.SetPts(0) } else { + // Increment by one frame yuv420p.frame.IncPts(1) } diff --git a/sys/ffmpeg61/avcodec.go b/sys/ffmpeg61/avcodec.go index 3e7f2c9..78f3dcd 100644 --- a/sys/ffmpeg61/avcodec.go +++ b/sys/ffmpeg61/avcodec.go @@ -26,12 +26,12 @@ type ( AVCodecContext C.AVCodecContext AVCodecFlag C.uint32_t AVCodecFlag2 C.uint32_t + AVCodecID C.enum_AVCodecID AVCodecMacroblockDecisionMode C.int AVCodecParameters C.AVCodecParameters AVCodecParser C.AVCodecParser AVCodecParserContext C.AVCodecParserContext AVProfile C.AVProfile - AVCodecID C.enum_AVCodecID ) type jsonAVCodec struct { @@ -186,6 +186,7 @@ func (ctx *AVCodecContext) MarshalJSON() ([]byte, error) { Height: int(ctx.height), SampleAspectRatio: AVRational(ctx.sample_aspect_ratio), Framerate: AVRational(ctx.framerate), + TimeBase: (AVRational)(ctx.time_base), }) case C.AVMEDIA_TYPE_AUDIO: return json.Marshal(jsonAVCodecContext{ diff --git a/sys/ffmpeg61/avcodec_parameters.go b/sys/ffmpeg61/avcodec_parameters.go index 917fcc3..833c303 100644 --- a/sys/ffmpeg61/avcodec_parameters.go +++ b/sys/ffmpeg61/avcodec_parameters.go @@ -30,7 +30,6 @@ type jsonAVCodecParameterVideo struct { Width int `json:"width"` Height int `json:"height"` SampleAspectRatio AVRational `json:"sample_aspect_ratio,omitempty"` - Framerate AVRational `json:"framerate,omitempty"` } type jsonAVCodecParameters struct { @@ -66,7 +65,6 @@ func (ctx AVCodecParameters) MarshalJSON() ([]byte, error) { Width: int(ctx.width), Height: int(ctx.height), SampleAspectRatio: AVRational(ctx.sample_aspect_ratio), - Framerate: AVRational(ctx.framerate), } } @@ -181,14 +179,6 @@ func (ctx *AVCodecParameters) SetSampleAspectRatio(aspect AVRational) { ctx.sample_aspect_ratio = C.AVRational(aspect) } -func (ctx *AVCodecParameters) Framerate() AVRational { - return AVRational(ctx.framerate) -} - -func (ctx *AVCodecParameters) SetFramerate(rate AVRational) { - ctx.framerate = C.AVRational(rate) -} - // Video func (ctx *AVCodecParameters) Width() int { return int(ctx.width) diff --git a/sys/ffmpeg61/avdevice.go b/sys/ffmpeg61/avdevice.go index daf8e20..9a120f2 100644 --- a/sys/ffmpeg61/avdevice.go +++ b/sys/ffmpeg61/avdevice.go @@ -69,17 +69,30 @@ func (ctx *AVDeviceInfo) String() string { //////////////////////////////////////////////////////////////////////////////// // PROPERTIES +// list of autodetected devices func (ctx *AVDeviceInfoList) Devices() []*AVDeviceInfo { - if ctx.nb_devices == 0 || ctx.devices == nil { + if ctx == nil || ctx.nb_devices == 0 || ctx.devices == nil { return nil } return cAVDeviceInfoSlice(unsafe.Pointer(ctx.devices), ctx.nb_devices) } +// number of autodetected devices func (ctx *AVDeviceInfoList) NumDevices() int { + if ctx == nil { + return 0 + } return int(ctx.nb_devices) } +// index of default device or -1 if no default +func (ctx *AVDeviceInfoList) Default() int { + if ctx == nil { + return -1 + } + return int(ctx.default_device) +} + func (ctx *AVDeviceInfo) Name() string { return C.GoString(ctx.device_name) } diff --git a/sys/ffmpeg61/avdevice_input.go b/sys/ffmpeg61/avdevice_input.go index 3b1b06c..9c3c0af 100644 --- a/sys/ffmpeg61/avdevice_input.go +++ b/sys/ffmpeg61/avdevice_input.go @@ -1,7 +1,6 @@ package ffmpeg import ( - "fmt" "unsafe" ) @@ -60,7 +59,6 @@ func AVDevice_list_input_sources(device *AVInputFormat, device_name string, devi // Get list var list *C.struct_AVDeviceInfoList if ret := int(C.avdevice_list_input_sources((*C.struct_AVInputFormat)(device), cName, dict, &list)); ret < 0 { - fmt.Println("C list", device, cName, dict, list, ret) return nil, AVError(ret) } diff --git a/sys/ffmpeg61/avformat_avio_test.go b/sys/ffmpeg61/avformat_avio_test.go index 02fd8c5..57ceca6 100644 --- a/sys/ffmpeg61/avformat_avio_test.go +++ b/sys/ffmpeg61/avformat_avio_test.go @@ -55,7 +55,7 @@ func Test_avio_001(t *testing.T) { if n == AVERROR_EOF { break } - fmt.Println("N=", n, string(buf[:n])) + t.Log("N=", n, string(buf[:n])) } // Free the context diff --git a/sys/ffmpeg61/avformat_demux.go b/sys/ffmpeg61/avformat_demux.go index 132acbd..2cdd6d3 100644 --- a/sys/ffmpeg61/avformat_demux.go +++ b/sys/ffmpeg61/avformat_demux.go @@ -112,6 +112,8 @@ func AVFormat_read_frame(ctx *AVFormatContext, packet *AVPacket) error { if err := AVError(C.av_read_frame((*C.struct_AVFormatContext)(ctx), (*C.struct_AVPacket)(packet))); err < 0 { if err == AVERROR_EOF { return io.EOF + } else if err.IsErrno(syscall.EAGAIN) { + return syscall.EAGAIN } else { return err } diff --git a/sys/ffmpeg61/avutil_frame.go b/sys/ffmpeg61/avutil_frame.go index 88ff4e6..00cfa0e 100644 --- a/sys/ffmpeg61/avutil_frame.go +++ b/sys/ffmpeg61/avutil_frame.go @@ -42,7 +42,7 @@ type jsonAVFrame struct { *jsonAVVideoFrame NumPlanes int `json:"num_planes,omitempty"` PlaneBytes []int `json:"plane_bytes,omitempty"` - Pts AVTimestamp `json:"pts,omitempty"` + Pts AVTimestamp `json:"pts"` TimeBase AVRational `json:"time_base,omitempty"` } @@ -123,6 +123,10 @@ func AVUtil_frame_get_buffer(frame *AVFrame, align bool) error { return nil } +func AVUtil_frame_is_allocated(frame *AVFrame) bool { + return frame.data[0] != nil +} + // Ensure that the frame data is writable, avoiding data copy if possible. // Do nothing if the frame is writable, allocate new buffers and copy the data if it is not. // Non-refcounted frames behave as non-writable, i.e. a copy is always made. @@ -146,6 +150,7 @@ func AVUtil_frame_get_num_planes(frame *AVFrame) int { // Video return AVUtil_pix_fmt_count_planes(AVPixelFormat(frame.format)) } + // Other return 0 } @@ -246,10 +251,6 @@ func (ctx *AVFrame) SetPts(pts int64) { ctx.pts = C.int64_t(pts) } -func (ctx *AVFrame) BestEffortTs() int64 { - return int64(ctx.best_effort_timestamp) -} - func (ctx *AVFrame) TimeBase() AVRational { return AVRational(ctx.time_base) } @@ -283,6 +284,10 @@ func (ctx *AVFrame) Planesize(plane int) int { // Return all strides. func (ctx *AVFrame) linesizes() []int { var linesizes []int + + if !AVUtil_frame_is_allocated(ctx) { + return nil + } for i := 0; i < AVUtil_frame_get_num_planes(ctx); i++ { linesizes = append(linesizes, ctx.Linesize(i)) } @@ -292,6 +297,10 @@ func (ctx *AVFrame) linesizes() []int { // Return all planes sizes func (ctx *AVFrame) planesizes() []int { var planesizes []int + + if !AVUtil_frame_is_allocated(ctx) { + return nil + } for i := 0; i < AVUtil_frame_get_num_planes(ctx); i++ { planesizes = append(planesizes, ctx.Planesize(i)) } @@ -305,41 +314,65 @@ func (ctx *AVFrame) Bytes(plane int) []byte { // Returns a plane as a uint8 array. func (ctx *AVFrame) Uint8(plane int) []uint8 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cUint8Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane))) } // Returns a plane as a int8 array. func (ctx *AVFrame) Int8(plane int) []int8 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cInt8Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane))) } // Returns a plane as a uint16 array. func (ctx *AVFrame) Uint16(plane int) []uint16 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cUint16Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>1)) } // Returns a plane as a int16 array. func (ctx *AVFrame) Int16(plane int) []int16 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cInt16Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>1)) } // Returns a plane as a uint32 array. func (ctx *AVFrame) Uint32(plane int) []uint32 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cUint32Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>2)) } // Returns a plane as a int32 array. func (ctx *AVFrame) Int32(plane int) []int32 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cInt32Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>2)) } // Returns a plane as a float32 array. func (ctx *AVFrame) Float32(plane int) []float32 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cFloat32Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>2)) } // Returns a plane as a float64 array. func (ctx *AVFrame) Float64(plane int) []float64 { + if !AVUtil_frame_is_allocated(ctx) { + return nil + } return cFloat64Slice(unsafe.Pointer(ctx.data[plane]), C.int(ctx.Planesize(plane)>>3)) } diff --git a/type.go b/type.go index 8a3aba1..ffb50e2 100644 --- a/type.go +++ b/type.go @@ -1,5 +1,9 @@ package media +import ( + "encoding/json" +) + /////////////////////////////////////////////////////////////////////////////// // TYPES @@ -10,27 +14,35 @@ type Type int // GLOBALS const ( - NONE Type = 0 // Type is not defined - VIDEO Type = (1 << iota) // Type is video - AUDIO // Type is audio - SUBTITLE // Type is subtitle - DATA // Type is data - UNKNOWN // Type is unknown - ANY = NONE // Type is any (used for filtering) - mintype = VIDEO - maxtype = UNKNOWN + NONE Type = 0 // Type is not defined + VIDEO Type = (1 << iota) // Type is video + AUDIO // Type is audio + SUBTITLE // Type is subtitle + DATA // Type is data + UNKNOWN // Type is unknown + INPUT // Type is input format + OUTPUT // Type is output format + DEVICE // Type is input or output device + maxtype + mintype = VIDEO + ANY = NONE // Type is any (used for filtering) ) /////////////////////////////////////////////////////////////////////////////// // STINGIFY +// Return the type as a string +func (t Type) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + // Return the type as a string func (t Type) String() string { if t == NONE { return t.FlagString() } str := "" - for f := mintype; f <= maxtype; f <<= 1 { + for f := mintype; f < maxtype; f <<= 1 { if t&f == f { str += "|" + f.FlagString() } @@ -51,6 +63,12 @@ func (t Type) FlagString() string { return "SUBTITLE" case DATA: return "DATA" + case INPUT: + return "INPUT" + case OUTPUT: + return "OUTPUT" + case DEVICE: + return "DEVICE" default: return "UNKNOWN" }