diff --git a/README.md b/README.md index bab6271..0dbce31 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,74 @@ # go-media -This module provides an interface for media services, including: +This module provides an interface for media services, mostly based on bindings +for [FFmpeg](https://ffmpeg.org/). It is designed to be used in a pipeline +for processing media files, and is not a standalone application. -* Bindings in golang for [FFmpeg 7.1](https://ffmpeg.org/); -* Opening media files, devices and network sockets for reading and writing; -* Retrieving metadata and artwork from audio and video media; -* Re-multiplexing media files from one format to another; -* Fingerprinting audio files to identify music. +You'd want to use this module if you want to integrate media processing into an +existing pipeline, not necessarily to build a standalone application, where +you can already use a command like [FFmpeg](https://ffmpeg.org/) or +[GStreamer](https://gstreamer.freedesktop.org/). ## Current Status This module is currently in development and subject to change. If there are any specific features you are interested in, please see below "Contributing & Distribution" below. +## What do you want to do? + +Here is some examples of how you might want to use this module: + +| Use Case | Examples | +|----------|-----------------| +| Use low-level bindings in golang for [FFmpeg 7.1](https://ffmpeg.org/) | [here]() | +| Opening media files, devices and network sockets for reading and writing | [here]() | +| Retrieving metadata, artwork or thumbnails from audio and video media | [here]() | +| Re-multiplexing media files from one format to another | [here]() | +| Encoding and decoding audio, video and subtitle streams | [here]() | +| Resampling audio and resizing video streams | [here]() | +| Applying filters and effects to audio and video streams | [here]() | +| Fingerprinting audio files to identify music | [here]() | +| Creating an audio or video player | [here]() | + ## Requirements -If you're building for docker, then you can simply run the following command. This creates a docker -image with all the dependencies installed. +There are two ways to satisfy the dependencies on FFmpeg: -```bash -DOCKER_REGISTRY=docker.io/user make docker -``` +1. The module is based on [FFmpeg 7.1](https://ffmpeg.org/) and requires you to have installed the libraries + and headers for FFmpeg. You can install the libraries using your package manager. +2. The module can download the source code for FFmpeg and build static libraries and headers + for you. This is done using the `make` command. -However, it's more likely that you want to build the bindings. To do so, compile the FFmpeg libraries -first: +Either way, in order to integrate the module into your golang code, you need to have satisfied these +dependencies and use a specific set of flags to compile your code. + +### Building FFmpeg + +To build FFmpeg, you need to have a compiler, nasm, pkg-config and make. + +#### Debian/Ubuntu ```bash -# Debian/Ubuntu -apt install libfreetype-dev libmp3lame-dev libopus-dev libvorbis-dev libvpx-dev libx264-dev libx265-dev libnuma-dev +# Required +apt install \ + build-essential cmake nasm curl + +# Optional +apt install \ + libfreetype-dev libmp3lame-dev libopus-dev libvorbis-dev libvpx-dev \ + libx264-dev libx265-dev libnuma-dev + +# Make ffmpeg git clone github.com/mutablelogic/go-media cd go-media make ffmpeg ``` +#### Fedora + +TODO + ```bash # Fedora dnf install freetype-devel lame-devel opus-devel libvorbis-devel libvpx-devel x264-devel x265-devel numactl-devel @@ -42,6 +77,11 @@ cd go-media make ffmpeg ``` + +#### MacOS Homebrew + +TODO + ```bash # Homebrew brew install freetype lame opus libvorbis libvpx x264 x265 @@ -51,7 +91,11 @@ make ffmpeg ``` This will place the static libraries in the `build/install` folder which you can refer to when compiling your -golang code. For example, here's a typical compile or run command on a Mac: +golang code. + +## Linking to FFmpeg + +For example, here's a typical compile or run command on a Mac: ```bash PKG_CONFIG_PATH="${PWD}/build/install/lib/pkgconfig" \ @@ -59,7 +103,6 @@ PKG_CONFIG_PATH="${PWD}/build/install/lib/pkgconfig" \ CGO_LDFLAGS_ALLOW="-(W|D).*" \ go build -o build/media ./cmd/media ``` - ### Demultiplexing ```go diff --git a/cmd/examples/encode/context.go b/cmd/examples/encode/context.go deleted file mode 100644 index 97c8708..0000000 --- a/cmd/examples/encode/context.go +++ /dev/null @@ -1,33 +0,0 @@ -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 6b3c5d6..110dc2f 100644 --- a/cmd/examples/encode/main.go +++ b/cmd/examples/encode/main.go @@ -7,6 +7,7 @@ import ( "io" "log" "os" + "os/signal" "syscall" // Packages @@ -48,7 +49,8 @@ func main() { defer audio.Close() // Bail out when we receive a signal - ctx := ContextForSignal(os.Interrupt, syscall.SIGQUIT) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGQUIT) + defer cancel() // Write 90 seconds, passing video and audio frames to the encoder // and returning io.EOF when the duration is reached diff --git a/cmd/media/encode.go b/cmd/media/encode.go new file mode 100644 index 0000000..5d222a9 --- /dev/null +++ b/cmd/media/encode.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "io" + "time" + + // Packages + media "github.com/mutablelogic/go-media" + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + generator "github.com/mutablelogic/go-media/pkg/generator" + server "github.com/mutablelogic/go-server" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type EncodeCommands struct { + EncodeTest EncodeTest `cmd:"" group:"TRANSCODE" help:"Encode a test file"` +} + +type EncodeTest struct { + Out string `arg:"" type:"path" help:"Output filename"` + Duration time.Duration `help:"Duration of the test file"` +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (cmd *EncodeTest) Run(app server.Cmd) error { + // Create a manager + manager, err := ffmpeg.NewManager() + if err != nil { + return err + } + + // TODO: Guess the output format + formats := manager.Formats(media.OUTPUT, cmd.Out) + if len(formats) == 0 { + return media.ErrBadParameter.With("unable to guess the output format for %q", cmd.Out) + } + fmt.Println(formats) + + // Streams + streams := []media.Par{ + manager.MustVideoPar("yuv420p", 1280, 720, 25), + manager.MustAudioPar("fltp", "mono", 22050), + } + + // Create a writer with two streams + writer, err := manager.Create(cmd.Out, nil, nil, streams...) + if err != nil { + return err + } + defer writer.Close() + + // Make an video generator which can generate frames with the same parameters as the video stream + video, err := generator.NewEBU(writer.(*ffmpeg.Writer).Stream(1).Par()) + if err != nil { + return err + } + defer video.Close() + + // Make an audio generator which can generate a 1KHz tone + // at -5dB with the same parameters as the audio stream + audio, err := generator.NewSine(1000, -5, writer.(*ffmpeg.Writer).Stream(2).Par()) + if err != nil { + return err + } + defer audio.Close() + + // Write until CTRL+C or duration is reached + var ts uint + manager.Errorf("Press CTRL+C to stop encoding\n") + return manager.Encode(app.Context(), writer, func(stream int) (media.Frame, error) { + var frame *ffmpeg.Frame + switch stream { + case 1: + frame = video.Frame() + case 2: + frame = audio.Frame() + } + + // Print the timestamp in seconds + if newts := uint(frame.Ts()); newts != ts { + ts = newts + manager.Errorf("Writing frame at %s\r", time.Duration(ts)*time.Second) + } + + // Check for end of stream + if cmd.Duration == 0 || frame.Ts() < cmd.Duration.Seconds() { + return frame, nil + } else { + return frame, io.EOF + } + }) +} diff --git a/cmd/media/main.go b/cmd/media/main.go index 988f5e6..72c9805 100644 --- a/cmd/media/main.go +++ b/cmd/media/main.go @@ -16,6 +16,7 @@ type CLI struct { CodecCommands FingerprintCommands VersionCommands + EncodeCommands } /////////////////////////////////////////////////////////////////////////////// diff --git a/manager.go b/manager.go index a9ae721..448c104 100644 --- a/manager.go +++ b/manager.go @@ -52,13 +52,19 @@ type Manager interface { // of the caller to also close the writer when done. //Write(io.Writer, Format, []Metadata, ...Par) (Media, error) - // Return audio parameters for encoding - // ChannelLayout, SampleFormat, Samplerate - //AudioPar(string, string, int) (Par, error) + // Return audio parameters for encoding with SampleFormat, ChannelLayout, Samplerate + AudioPar(string, string, uint) (Par, error) - // Return video parameters for encoding - // Width, Height, PixelFormat - //VideoPar(int, int, string) (Par, error) + // Return audio parameters for encoding with SampleFormat, ChannelLayout, Samplerate, + // panics on error + MustAudioPar(string, string, uint) Par + + // Return video parameters for encoding with PixelFormat, Width, Height, Framerate + VideoPar(string, uint, uint, float64) (Par, error) + + // Return video parameters for encoding with PixelFormat, Width, Height, Framerate, + // panics on error + MustVideoPar(string, uint, uint, float64) Par // Return codec parameters for audio encoding // Codec name and AudioParameters @@ -102,7 +108,10 @@ type Manager interface { // Decode an input stream, determining the streams to be decoded // and the function to accept the decoded frames. If MapFunc is nil, // all streams are passed through (demultiplexing). - Decode(context.Context, Media, MapFunc, FrameFunc) error + Decode(context.Context, Media, MapFunc, DecodeFrameFunc) error + + // Encode an output stream + Encode(context.Context, Media, EncodeFrameFn) error } // MapFunc return parameters if a stream should be decoded, @@ -113,10 +122,17 @@ type MapFunc func(int, Par) (Par, error) // FrameFunc is a function which is called to send a frame after decoding. It should // return nil to continue decoding or io.EOF to stop. -type FrameFunc func(int, Frame) error +type DecodeFrameFunc func(int, Frame) error + +// EncodeFrameFn is a function which is called to receive a frame to encode. It should +// return nil to continue encoding or io.EOF to stop encoding. +type EncodeFrameFn func(int) (Frame, error) // Parameters for a stream or frame -type Par interface{} +type Par interface { + // The type of the parameters, which can be AUDIO, VIDEO or SUBTITLE + Type() Type +} // A frame of decoded data type Frame interface{} @@ -132,6 +148,9 @@ type Format interface { // Description of the format Description() string + + // Return AUDIO, VIDEO or SUBTITLE codec parameters + CodecPar(Type) Par } // A container format for a media file, reader, device or diff --git a/pkg/ffmpeg/format.go b/pkg/ffmpeg/format.go index 90eff0e..704f57a 100644 --- a/pkg/ffmpeg/format.go +++ b/pkg/ffmpeg/format.go @@ -156,3 +156,14 @@ func (f *Format) Description() string { return f.metaFormat.Name } } + +// Return AUDIO, VIDEO or SUBTITLE codec parameters +func (f *Format) CodecPar(t media.Type) media.Par { + switch { + case f.Output != nil: + // TODO + case f.Input != nil: + // TODO + } + return nil +} diff --git a/pkg/ffmpeg/manager.go b/pkg/ffmpeg/manager.go index 7fbb697..bdef574 100644 --- a/pkg/ffmpeg/manager.go +++ b/pkg/ffmpeg/manager.go @@ -2,6 +2,7 @@ package ffmpeg import ( "context" + "fmt" "io" "slices" "strings" @@ -130,7 +131,7 @@ func (manager *Manager) Create(url string, format media.Format, meta []media.Met if !ok || par == nil { return nil, media.ErrBadParameter.With("invalid stream parameters") } - o = append(o, OptStream(i, par)) + o = append(o, OptStream(i+1, par)) } // Create the writer @@ -270,6 +271,15 @@ func (manager *Manager) Formats(t media.Type, name ...string) []media.Format { } } + // Specifically determine an output by guessing filename + if t.Is(media.OUTPUT) && !t.Is(media.ANY) { + for _, name := range name { + if ofmt := ff.AVFormat_guess_format("", name, ""); ofmt != nil { + result = append(result, newOutputFormats(ofmt, media.OUTPUT)...) + } + } + } + // Return if DEVICE is not requested if !t.Is(media.DEVICE) && !t.Is(media.ANY) { return result @@ -415,6 +425,37 @@ func (manager *Manager) Codecs(t media.Type, name ...string) []media.Metadata { return result } +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - CODEC PARAMETERS + +// Create new audio parameters with sample format, channel layout and sample rate +func (manager *Manager) AudioPar(samplefmt, channellayout string, samplerate uint) (media.Par, error) { + return NewAudioPar(samplefmt, channellayout, int(samplerate)) +} + +// Create new audio parameters with sample format, channel layout and sample rate +func (manager *Manager) MustAudioPar(samplefmt, channellayout string, samplerate uint) media.Par { + par, err := manager.AudioPar(samplefmt, channellayout, samplerate) + if err != nil { + panic(err) + } + return par +} + +// Create new video parameters with pixel format, width, height and frame rate +func (manager *Manager) VideoPar(pixelfmt string, width, height uint, framerate float64) (media.Par, error) { + return NewVideoPar(pixelfmt, fmt.Sprintf("%dx%d", width, height), framerate) +} + +// Create new video parameters with pixel format, width, height and frame rate +func (manager *Manager) MustVideoPar(pixelfmt string, width, height uint, framerate float64) media.Par { + par, err := manager.VideoPar(pixelfmt, width, height, framerate) + if err != nil { + panic(err) + } + return par +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS - LOGGING @@ -436,21 +477,34 @@ func (manager *Manager) Infof(v string, args ...any) { /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS - DECODING -func (manager *Manager) Decode(ctx context.Context, m media.Media, mapFunc media.MapFunc, frameFunc media.FrameFunc) error { +func (manager *Manager) Decode(ctx context.Context, m media.Media, mapFunc media.MapFunc, frameFunc media.DecodeFrameFunc) error { return media.ErrNotImplemented.With("decoding not implemented") } /* - // Check if the media is valid - if m == nil || !m.Type().Is(media.INPUT) { - return media.ErrBadParameter.With("invalid media, cannot decode") - } - // Get the concrete reader object - reader, ok := m.(*Reader) - if !ok || reader == nil { - return media.ErrBadParameter.With("invalid media, cannot decode") - } // Perform the decode return reader.Decode(ctx, mapFunc, frameFunc) } */ + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - ENCODING + +func (manager *Manager) Encode(ctx context.Context, m media.Media, frameFunc media.EncodeFrameFn) error { + // Get the concrete writer object + writer, ok := m.(*Writer) + if !ok || writer == nil || !m.Type().Is(media.OUTPUT) { + return media.ErrBadParameter.With("invalid media, cannot encode") + } + if frameFunc == nil { + return media.ErrBadParameter.With("nil frame function") + } + return writer.Encode(ctx, func(stream int) (*Frame, error) { + frame, err := frameFunc(stream) + if frame, ok := frame.(*Frame); !ok { + return nil, media.ErrBadParameter.With("invalid frame: ", frame) + } else { + return frame, err + } + }, nil) +} diff --git a/pkg/ffmpeg/par.go b/pkg/ffmpeg/par.go index 8d181ff..703e345 100644 --- a/pkg/ffmpeg/par.go +++ b/pkg/ffmpeg/par.go @@ -8,9 +8,6 @@ import ( // Packages media "github.com/mutablelogic/go-media" ff "github.com/mutablelogic/go-media/sys/ffmpeg71" - - // Namespace imports - . "github.com/djthorpe/go-errors" ) /////////////////////////////////////////////////////////////////////////////// @@ -40,7 +37,7 @@ func NewAudioPar(samplefmt string, channellayout string, samplerate int, opts .. // Sample Format if samplefmt_ := ff.AVUtil_get_sample_fmt(samplefmt); samplefmt_ == ff.AV_SAMPLE_FMT_NONE { - return nil, ErrBadParameter.Withf("unknown sample format %q", samplefmt) + return nil, media.ErrBadParameter.Withf("unknown sample format %q", samplefmt) } else { par.SetSampleFormat(samplefmt_) } @@ -48,14 +45,14 @@ func NewAudioPar(samplefmt string, channellayout string, samplerate int, opts .. // Channel layout var ch ff.AVChannelLayout if err := ff.AVUtil_channel_layout_from_string(&ch, channellayout); err != nil { - return nil, ErrBadParameter.Withf("channel layout %q", channellayout) + return nil, media.ErrBadParameter.Withf("channel layout %q", channellayout) } else if err := par.SetChannelLayout(ch); err != nil { return nil, err } // Sample rate if samplerate <= 0 { - return nil, ErrBadParameter.Withf("negative or zero samplerate %v", samplerate) + return nil, media.ErrBadParameter.Withf("negative or zero samplerate %v", samplerate) } else { par.SetSamplerate(samplerate) } @@ -66,21 +63,21 @@ func NewAudioPar(samplefmt string, channellayout string, samplerate int, opts .. // 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) { +func NewVideoPar(pixelfmt, 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 { - return nil, ErrBadParameter.Withf("unknown pixel format %q", pixfmt) + if pixfmt_ := ff.AVUtil_get_pix_fmt(pixelfmt); pixfmt_ == ff.AV_PIX_FMT_NONE { + return nil, media.ErrBadParameter.Withf("unknown pixel format %q", pixelfmt) } else { par.SetPixelFormat(pixfmt_) } // Frame size if w, h, err := ff.AVUtil_parse_video_size(size); err != nil { - return nil, ErrBadParameter.Withf("size %q", size) + return nil, media.ErrBadParameter.Withf("size %q", size) } else { par.SetWidth(w) par.SetHeight(h) @@ -88,7 +85,7 @@ func NewVideoPar(pixfmt string, size string, framerate float64, opts ...media.Me // Frame rate and timebase if framerate < 0 { - return nil, ErrBadParameter.Withf("negative framerate %v", framerate) + return nil, media.ErrBadParameter.Withf("negative framerate %v", framerate) } else if framerate > 0 { par.timebase = ff.AVUtil_rational_invert(ff.AVUtil_rational_d2q(framerate, 1<<24)) } @@ -239,17 +236,17 @@ func (ctx *Par) validateAudioCodec(codec *ff.AVCodec) error { // the codec if len(sampleformats) > 0 { if !slices.Contains(sampleformats, ctx.SampleFormat()) { - return ErrBadParameter.Withf("unsupported sample format %v", ctx.SampleFormat()) + return media.ErrBadParameter.Withf("unsupported sample format %v", ctx.SampleFormat()) } } else if ctx.SampleFormat() == ff.AV_SAMPLE_FMT_NONE { - return ErrBadParameter.With("sample format not set") + return media.ErrBadParameter.With("sample format not set") } if len(samplerates) > 0 { if !slices.Contains(samplerates, ctx.Samplerate()) { - return ErrBadParameter.Withf("unsupported samplerate %v", ctx.Samplerate()) + return media.ErrBadParameter.Withf("unsupported samplerate %v", ctx.Samplerate()) } } else if ctx.Samplerate() == 0 { - return ErrBadParameter.With("samplerate not set") + return media.ErrBadParameter.With("samplerate not set") } if len(channellayouts) > 0 { valid := false @@ -261,10 +258,10 @@ func (ctx *Par) validateAudioCodec(codec *ff.AVCodec) error { } } if !valid { - return ErrBadParameter.Withf("unsupported channel layout %v", ctx.ChannelLayout()) + return media.ErrBadParameter.Withf("unsupported channel layout %v", ctx.ChannelLayout()) } } else if ctx.ChannelLayout().NumChannels() == 0 { - return ErrBadParameter.With("channel layout not set") + return media.ErrBadParameter.With("channel layout not set") } // Validated @@ -300,19 +297,19 @@ func (ctx *Par) validateVideoCodec(codec *ff.AVCodec) error { // the codec if len(pixelformats) > 0 { if !slices.Contains(pixelformats, ctx.PixelFormat()) { - return ErrBadParameter.Withf("unsupported pixel format %v", ctx.PixelFormat()) + return media.ErrBadParameter.Withf("unsupported pixel format %v", ctx.PixelFormat()) } } else if ctx.PixelFormat() == ff.AV_PIX_FMT_NONE { - return ErrBadParameter.With("pixel format not set") + return media.ErrBadParameter.With("pixel format not set") } if ctx.Width() == 0 || ctx.Height() == 0 { - return ErrBadParameter.Withf("invalid width %v or height %v", ctx.Width(), ctx.Height()) + return media.ErrBadParameter.Withf("invalid width %v or height %v", ctx.Width(), ctx.Height()) } if ctx.SampleAspectRatio().Num() == 0 || ctx.SampleAspectRatio().Den() == 0 { ctx.SetSampleAspectRatio(ff.AVUtil_rational(1, 1)) } if ctx.timebase.Num() == 0 || ctx.timebase.Den() == 0 { - return ErrBadParameter.With("framerate not set") + return media.ErrBadParameter.With("framerate not set") } else if len(framerates) > 0 { valid := false for _, fr := range framerates { @@ -322,7 +319,7 @@ func (ctx *Par) validateVideoCodec(codec *ff.AVCodec) error { } } if !valid { - return ErrBadParameter.Withf("unsupported framerate %v", ff.AVUtil_rational_invert(ctx.timebase)) + return media.ErrBadParameter.Withf("unsupported framerate %v", ff.AVUtil_rational_invert(ctx.timebase)) } } diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 96e8f9c..7571b41 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -4,6 +4,7 @@ import ( "io" // Packages + "github.com/mutablelogic/go-media/pkg/ffmpeg" )