diff --git a/Makefile b/Makefile index 06c2b5f..40ff60c 100755 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ # Paths to packages GO=$(shell which go) DOCKER=$(shell which docker) +PKG_CONFIG=$(shell which pkg-config) # Source version FFMPEG_VERSION=ffmpeg-7.1.1 @@ -63,13 +64,12 @@ ${BUILD_DIR}/${FFMPEG_VERSION}: # Configure ffmpeg .PHONY: ffmpeg-configure -ffmpeg-configure: mkdir ${BUILD_DIR}/${FFMPEG_VERSION} ffmpeg-dep +ffmpeg-configure: mkdir pkconfig-dep ${BUILD_DIR}/${FFMPEG_VERSION} ffmpeg-dep @echo "Configuring ${FFMPEG_VERSION} => ${PREFIX}" @cd ${BUILD_DIR}/${FFMPEG_VERSION} && ./configure \ - --enable-static --disable-doc --disable-programs \ + --disable-doc --disable-programs \ --prefix="$(shell realpath ${PREFIX})" \ - --pkg-config-flags="--static" \ - --extra-libs="-lpthread" \ + --enable-static --pkg-config="${PKG_CONFIG}" --pkg-config-flags="--static" --extra-libs="-lpthread" \ --enable-gpl --enable-nonfree ${FFMPEG_CONFIG} # Build ffmpeg @@ -161,14 +161,12 @@ test-ffmpeg: go-dep go-tidy ffmpeg chromaprint @${CGO_ENV} ${GO} test ./pkg/segmenter @echo ... test pkg/chromaprint @${CGO_ENV} ${GO} test ./pkg/chromaprint + @echo ... test pkg/avcodec + ${CGO_ENV} ${GO} test ./pkg/avcodec # @echo ... test pkg/ffmpeg # @${GO} test -v ./pkg/ffmpeg -# @echo ... test sys/chromaprint -# @${GO} test ./sys/chromaprint -# @echo ... test pkg/chromaprint -# @${GO} test ./pkg/chromaprint # @echo ... test pkg/file # @${GO} test ./pkg/file # @echo ... test pkg/generator @@ -198,6 +196,11 @@ go-dep: docker-dep: @test -f "${DOCKER}" && test -x "${DOCKER}" || (echo "Missing docker binary" && exit 1) +.PHONY: pkconfig-dep +pkconfig-dep: + @test -f "${PKG_CONFIG}" && test -x "${PKG_CONFIG}" || (echo "Missing pkg-config binary" && exit 1) + + .PHONY: mkdir mkdir: @echo Mkdir ${BUILD_DIR} @@ -218,15 +221,18 @@ clean: go-tidy # Check for FFmpeg dependencies .PHONY: ffmpeg-dep ffmpeg-dep: - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists libass && echo "--enable-libass")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists fdk-aac && echo "--enable-libfdk-aac")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists lame && echo "--enable-libmp3lame")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists freetype2 && echo "--enable-libfreetype")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists theora && echo "--enable-libtheora")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists vorbis && echo "--enable-libvorbis")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists opus && echo "--enable-libopus")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists x264 && echo "--enable-libx264")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists x265 && echo "--enable-libx265")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists xvid && echo "--enable-libxvid")) - $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell pkg-config --exists vpx && echo "--enable-libvpx")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists libass && echo "--enable-libass")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists fdk-aac && echo "--enable-libfdk-aac")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists lame && echo "--enable-libmp3lame")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists freetype2 && echo "--enable-libfreetype")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists vorbis && echo "--enable-libvorbis")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists opus && echo "--enable-libopus")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists x264 && echo "--enable-libx264")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists x265 && echo "--enable-libx265")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists xvid && echo "--enable-libxvid")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists vpx && echo "--enable-libvpx")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists libgcrypt && echo "--enable-gcrypt")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists aom && echo "--enable-libaom")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists libbluray && echo "--enable-libbluray")) + $(eval FFMPEG_CONFIG := $(FFMPEG_CONFIG) $(shell ${PKG_CONFIG} --exists dav1d && echo "--enable-libdav1d")) @echo "FFmpeg configuration: $(FFMPEG_CONFIG)" diff --git a/pkg/avcodec/codec.go b/pkg/avcodec/codec.go new file mode 100644 index 0000000..22e2329 --- /dev/null +++ b/pkg/avcodec/codec.go @@ -0,0 +1,281 @@ +package avcodec + +import ( + "encoding/json" + "fmt" + + // Packages + media "github.com/mutablelogic/go-media" + metadata "github.com/mutablelogic/go-media/pkg/metadata" + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Codec struct { + codec *ff.AVCodec + context *ff.AVCodecContext +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Return all codecs. Codecs can be either encoders or decoders. The argument +// can be ANY or any combination of INPUT, OUTPUT, VIDEO, AUDIO and SUBTITLE, +// in order to return a subset of codecs. +func Codecs(t media.Type) []media.Metadata { + result := make([]media.Metadata, 0, 100) + var opaque uintptr + for { + codec := ff.AVCodec_iterate(&opaque) + if codec == nil { + break + } + // Filter codecs by type + if t.Is(media.INPUT) && !ff.AVCodec_is_decoder(codec) { + continue + } + if t.Is(media.OUTPUT) && !ff.AVCodec_is_encoder(codec) { + continue + } + if t.Is(media.VIDEO) && codec.Type() != ff.AVMEDIA_TYPE_VIDEO { + continue + } + if t.Is(media.AUDIO) && codec.Type() != ff.AVMEDIA_TYPE_AUDIO { + continue + } + if t.Is(media.SUBTITLE) && codec.Type() != ff.AVMEDIA_TYPE_SUBTITLE { + continue + } + if codec.Capabilities().Is(ff.AV_CODEC_CAP_EXPERIMENTAL) { + // Skip experimental codecs + continue + } + result = append(result, metadata.New(codec.Name(), &Codec{codec, nil})) + } + return result +} + +// NewEncodingCodec returns an encoder by name. Options for encoding can be passed. +// Call Close() to release the codec. Codec options are listed at +// +func NewEncodingCodec(name string, opts ...Opt) (*Codec, error) { + return newCodec(ff.AVCodec_find_encoder_by_name(name), opts...) +} + +// NewDecodingCodec returns a decoder by name. Options for decoding can be passed. +// Call Close() to release the codec context. Codec options are listed at +// +func NewDecodingCodec(name string, opts ...Opt) (*Codec, error) { + return newCodec(ff.AVCodec_find_decoder_by_name(name), opts...) +} + +func newCodec(codec *ff.AVCodec, opts ...Opt) (*Codec, error) { + ctx := new(Codec) + + // Check parameters + if codec == nil { + return nil, media.ErrBadParameter.Withf("unknown codec %q", codec.Name()) + } + + // Apply options + o, err := applyOptions(opts) + if err != nil { + return nil, err + } + + // Create a dictionary of codec options + dict := ff.AVUtil_dict_alloc() + defer ff.AVUtil_dict_free(dict) + for _, opt := range o.meta { + if err := ff.AVUtil_dict_set(dict, opt.Key(), opt.Value(), ff.AV_DICT_APPEND); err != nil { + ff.AVUtil_dict_free(dict) + return nil, err + } + } + + // Codec context + if context := ff.AVCodec_alloc_context(codec); context == nil { + return nil, media.ErrInternalError.Withf("failed to allocate codec context for %q", codec.Name()) + } else if err := set_par(context, codec, o); err != nil { + ff.AVCodec_free_context(context) + return nil, err + } else if err := ff.AVCodec_open(context, codec, dict); err != nil { + ff.AVCodec_free_context(context) + return nil, err + } else { + ctx.context = context + } + + // TODO: Get the options which were not consumed + fmt.Println("TODO: Codec options not consumed:", dict) + + // Return success + return ctx, nil +} + +// Release the codec resources +func (ctx *Codec) Close() error { + ctx.codec = nil + if ctx != nil && ctx.context != nil { + ff.AVCodec_free_context(ctx.context) + ctx.context = nil + } + return nil +} + +//////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (ctx *Codec) MarshalJSON() ([]byte, error) { + if ctx != nil && ctx.codec != nil { + return ctx.codec.MarshalJSON() + } + if ctx != nil && ctx.context != nil { + return ctx.context.MarshalJSON() + } + return []byte("null"), nil +} + +func (ctx *Codec) String() string { + data, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (ctx *Codec) Type() media.Type { + var t media.Type + switch { + case ctx != nil && ctx.codec != nil: + if ff.AVCodec_is_decoder(ctx.codec) { + t |= media.INPUT + } + if ff.AVCodec_is_encoder(ctx.codec) { + t |= media.OUTPUT + } + t |= type2type(ctx.codec.Type()) + case ctx != nil && ctx.context != nil: + if ff.AVCodec_is_decoder(ctx.context.Codec()) { + t |= media.INPUT + } + if ff.AVCodec_is_encoder(ctx.context.Codec()) { + t |= media.OUTPUT + } + t |= type2type(ctx.context.Codec().Type()) + } + return t +} + +func (ctx *Codec) Name() string { + switch { + case ctx != nil && ctx.codec != nil: + return ctx.codec.Name() + case ctx != nil && ctx.context != nil: + return ctx.context.Codec().Name() + } + return "" +} + +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func type2type(t ff.AVMediaType) media.Type { + switch t { + case ff.AVMEDIA_TYPE_AUDIO: + return media.AUDIO + case ff.AVMEDIA_TYPE_VIDEO: + return media.VIDEO + case ff.AVMEDIA_TYPE_SUBTITLE: + return media.SUBTITLE + case ff.AVMEDIA_TYPE_ATTACHMENT: + return media.DATA + case ff.AVMEDIA_TYPE_DATA: + return media.DATA + } + return media.NONE +} + +func set_par(ctx *ff.AVCodecContext, codec *ff.AVCodec, opt *opt) error { + switch codec.Type() { + case ff.AVMEDIA_TYPE_AUDIO: + return set_audio_par(ctx, codec, opt) + case ff.AVMEDIA_TYPE_VIDEO: + return set_video_par(ctx, codec, opt) + case ff.AVMEDIA_TYPE_SUBTITLE: + return set_subtitle_par(ctx, codec, opt) + } + return nil +} + +func set_audio_par(ctx *ff.AVCodecContext, codec *ff.AVCodec, opt *opt) error { + // Channel layout + if ff.AVUtil_channel_layout_check(&opt.channel_layout) { + ctx.SetChannelLayout(opt.channel_layout) + } else if supported_layouts := codec.ChannelLayouts(); len(supported_layouts) > 0 { + ctx.SetChannelLayout(supported_layouts[0]) + } else { + ctx.SetChannelLayout(ff.AV_CHANNEL_LAYOUT_MONO) + } + + // Sample format + if opt.sample_format != ff.AV_SAMPLE_FMT_NONE { + ctx.SetSampleFormat(opt.sample_format) + } else if supported_formats := codec.SampleFormats(); len(supported_formats) > 0 { + ctx.SetSampleFormat(supported_formats[0]) + } + + // Sample rate + if opt.sample_rate > 0 { + ctx.SetSampleRate(opt.sample_rate) + } else if supported_rates := codec.SupportedSamplerates(); len(supported_rates) > 0 { + ctx.SetSampleRate(supported_rates[0]) + } + + // TODO: Time base + ctx.SetTimeBase(ff.AVUtil_rational(1, ctx.SampleRate())) + + return nil +} + +func set_video_par(ctx *ff.AVCodecContext, codec *ff.AVCodec, opt *opt) error { + // Pixel Format + if opt.pixel_format != ff.AV_PIX_FMT_NONE { + ctx.SetPixFmt(opt.pixel_format) + } else if supported_formats := codec.PixelFormats(); len(supported_formats) > 0 { + ctx.SetPixFmt(supported_formats[0]) + } else { + ctx.SetPixFmt(ff.AV_PIX_FMT_YUV420P) + } + + // Frame size + if opt.width > 0 { + ctx.SetWidth(opt.width) + } + if opt.height > 0 { + ctx.SetHeight(opt.height) + } + + // Frame rate + if !opt.frame_rate.IsZero() { + ctx.SetFramerate(opt.frame_rate) + } else if supported_rates := codec.SupportedFramerates(); len(supported_rates) > 0 { + ctx.SetFramerate(supported_rates[0]) + } + + // Time base + if frame_rate := ctx.Framerate(); !frame_rate.IsZero() { + ctx.SetTimeBase(ff.AVUtil_rational_invert(frame_rate)) + } + + return nil +} + +func set_subtitle_par(ctx *ff.AVCodecContext, codec *ff.AVCodec, opt *opt) error { + return nil +} diff --git a/pkg/avcodec/codec_test.go b/pkg/avcodec/codec_test.go new file mode 100644 index 0000000..ca58d42 --- /dev/null +++ b/pkg/avcodec/codec_test.go @@ -0,0 +1,78 @@ +package avcodec_test + +import ( + "testing" + + // Packages + media "github.com/mutablelogic/go-media" + avcodec "github.com/mutablelogic/go-media/pkg/avcodec" + assert "github.com/stretchr/testify/assert" +) + +func Test_codec_001(t *testing.T) { + assert := assert.New(t) + + codecs := avcodec.Codecs(media.OUTPUT | media.AUDIO) + assert.NotNil(codecs) + for _, meta := range codecs { + assert.NotNil(meta) + codec, err := avcodec.NewEncoder(meta.Key(), avcodec.WithSampleRate(22050)) + if assert.NoError(err) { + assert.NotNil(codec) + assert.Equal(media.OUTPUT|media.AUDIO, codec.Type()) + t.Log(codec) + assert.NoError(codec.Close()) + } + } +} + +func Test_codec_002(t *testing.T) { + assert := assert.New(t) + + codecs := avcodec.Codecs(media.OUTPUT | media.VIDEO) + assert.NotNil(codecs) + for _, meta := range codecs { + assert.NotNil(meta) + codec, err := avcodec.NewEncoder(meta.Key(), avcodec.WithFrameRate(1, 25), avcodec.WithFrameSize("hd720")) + if assert.NoError(err) { + assert.NotNil(codec) + assert.Equal(media.OUTPUT|media.VIDEO, codec.Type()) + t.Log(codec) + assert.NoError(codec.Close()) + } + } +} + +func Test_codec_003(t *testing.T) { + assert := assert.New(t) + + codecs := avcodec.Codecs(media.INPUT | media.AUDIO) + assert.NotNil(codecs) + for _, meta := range codecs { + assert.NotNil(meta) + codec, err := avcodec.NewDecoder(meta.Key()) + if assert.NoError(err) { + assert.NotNil(codec) + assert.Equal(media.INPUT|media.AUDIO, codec.Type()) + t.Log(codec) + assert.NoError(codec.Close()) + } + } +} + +func Test_codec_004(t *testing.T) { + assert := assert.New(t) + + codecs := avcodec.Codecs(media.INPUT | media.VIDEO) + assert.NotNil(codecs) + for _, meta := range codecs { + assert.NotNil(meta) + codec, err := avcodec.NewDecoder(meta.Key(), avcodec.WithFrameSize("hd720")) + if assert.NoError(err) { + assert.NotNil(codec) + assert.Equal(media.INPUT|media.VIDEO, codec.Type()) + t.Log(codec) + assert.NoError(codec.Close()) + } + } +} diff --git a/pkg/avcodec/doc.go b/pkg/avcodec/doc.go new file mode 100644 index 0000000..70e65ac --- /dev/null +++ b/pkg/avcodec/doc.go @@ -0,0 +1,5 @@ +/* +avcodec is roughly a wrapper around FFmpeg's libavcodec library, which provides encoding, +decoding and transcoding of audio and video streams. +*/ +package avcodec diff --git a/pkg/avcodec/encode.go b/pkg/avcodec/encode.go new file mode 100644 index 0000000..e84d6bf --- /dev/null +++ b/pkg/avcodec/encode.go @@ -0,0 +1,24 @@ +package avcodec + +import ( + + // Packages + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type EncodingContext struct { + codec *Codec + stream *ff.AVStream + packet *ff.AVPacket +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create an encoding context (a stream and a packet) with the given codec +func NewEncodingContext(codec *Codec) (*Encoder, error) { + +} diff --git a/pkg/avcodec/opt.go b/pkg/avcodec/opt.go new file mode 100644 index 0000000..530c628 --- /dev/null +++ b/pkg/avcodec/opt.go @@ -0,0 +1,122 @@ +package avcodec + +import ( + // Packages + media "github.com/mutablelogic/go-media" + metadata "github.com/mutablelogic/go-media/pkg/metadata" + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opt struct { + // Audio parameters + channel_layout ff.AVChannelLayout + sample_format ff.AVSampleFormat + sample_rate int + + // Video parameters + width, height int + pixel_format ff.AVPixelFormat + frame_rate ff.AVRational + pixel_ratio ff.AVRational + + // Codec options + meta []*metadata.Metadata +} + +type Opt func(*opt) error + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func applyOptions(opts []Opt) (*opt, error) { + o := new(opt) + o.sample_format = ff.AV_SAMPLE_FMT_NONE + o.pixel_format = ff.AV_PIX_FMT_NONE + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + return o, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func WithOpt(key string, value any) Opt { + return func(o *opt) error { + o.meta = append(o.meta, metadata.New(key, value)) + return nil + } +} + +func WithChannelLayout(v string) Opt { + return func(o *opt) error { + if err := ff.AVUtil_channel_layout_from_string(&o.channel_layout, v); err != nil { + return media.ErrBadParameter.Withf("unknown channel layout %q", v) + } + return nil + } +} + +func WithSampleFormat(v string) Opt { + return func(o *opt) error { + if samplefmt := ff.AVUtil_get_sample_fmt(v); samplefmt == ff.AV_SAMPLE_FMT_NONE { + return media.ErrBadParameter.Withf("unknown sample format %q", v) + } else { + o.sample_format = samplefmt + } + return nil + } + +} + +func WithSampleRate(v int) Opt { + return func(o *opt) error { + if v <= 0 { + return media.ErrBadParameter.Withf("negative or zero samplerate %v", v) + } else { + o.sample_rate = v + } + return nil + } +} + +func WithPixelFormat(v string) Opt { + return func(o *opt) error { + if pixfmt := ff.AVUtil_get_pix_fmt(v); pixfmt == ff.AV_PIX_FMT_NONE { + return media.ErrBadParameter.Withf("unknown pixel format %q", v) + } else { + o.pixel_format = pixfmt + } + return nil + } +} + +func WithFrameSize(v string) Opt { + return func(o *opt) error { + if width, height, err := ff.AVUtil_parse_video_size(v); err != nil { + return media.ErrBadParameter.Withf("size %q", v) + } else { + o.width = width + o.height = height + } + return nil + } +} + +func WithFrameRate(num, den int) Opt { + return func(o *opt) error { + o.frame_rate = ff.AVUtil_rational(num, den) + if o.frame_rate.IsZero() { + return media.ErrBadParameter.Withf("zero frame rate %v/%v", num, den) + } + if o.frame_rate.Num() <= 0 || o.frame_rate.Den() <= 0 { + return media.ErrBadParameter.Withf("negative frame rate %v/%v", num, den) + } + return nil + } +} diff --git a/pkg/avfilter/filter.go b/pkg/avfilter/filter.go new file mode 100644 index 0000000..1a60cc5 --- /dev/null +++ b/pkg/avfilter/filter.go @@ -0,0 +1,50 @@ +package avfilter + +import ( + media "github.com/mutablelogic/go-media" + "github.com/mutablelogic/go-media/pkg/ffmpeg" + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Filter struct { + ctx *ff.AVFilter +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Return all filters +func Filters() []media.Metadata { + result := make([]media.Metadata, 0, 100) + var opaque uintptr + for { + filter := ff.AVFilter_iterate(&opaque) + if filter == nil { + break + } + result = append(result, ffmpeg.NewMetadata(filter.Name(), &Filter{filter})) + } + return result +} + +// Create a new filter by name +func NewFilter(name string) *Filter { + if filter := ff.AVFilter_get_by_name(name); filter != nil { + return &Filter{ctx: filter} + } + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (f *Filter) Name() string { + return f.ctx.Name() +} + +func (f *Filter) Description() string { + return f.ctx.Description() +} diff --git a/pkg/avfilter/filter_test.go b/pkg/avfilter/filter_test.go new file mode 100644 index 0000000..bc8e129 --- /dev/null +++ b/pkg/avfilter/filter_test.go @@ -0,0 +1,22 @@ +package avfilter_test + +import ( + "testing" + + // Packages + avfilter "github.com/mutablelogic/go-media/pkg/avfilter" + assert "github.com/stretchr/testify/assert" +) + +func Test_filter_001(t *testing.T) { + assert := assert.New(t) + + filters := avfilter.Filters() + assert.NotNil(filters) + assert.NotEmpty(filters) + for _, filter := range filters { + filter2 := avfilter.NewFilter(filter.Key()) + assert.NotNil(filter2) + assert.Equal(filter2, filter.Any()) + } +} diff --git a/pkg/avfilter/graph.go b/pkg/avfilter/graph.go new file mode 100644 index 0000000..6abd7ef --- /dev/null +++ b/pkg/avfilter/graph.go @@ -0,0 +1,83 @@ +package avfilter + +import ( + "errors" + "fmt" + + // Packages + media "github.com/mutablelogic/go-media" + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Graph struct { + ctx *ff.AVFilterGraph + in []*ff.AVFilterInOut + out []*ff.AVFilterInOut +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Allocates a new filter graph and returns it. +func NewGraph() *Graph { + graph := new(Graph) + if ctx := ff.AVFilterGraph_alloc(); ctx == nil { + return nil + } else { + graph.ctx = ctx + } + // Return the graph + return graph +} + +// Parse a graph description and return it. +func ParseGraph(desc string) (*Graph, error) { + graph := NewGraph() + if graph == nil { + return nil, media.ErrInternalError.With("failed to allocate filter graph") + } + + // Parse the graph, and set the inputs and outputs + in, out, err := ff.AVFilterGraph_parse(graph.ctx, desc) + if err != nil { + return nil, errors.Join(err, graph.Close()) + } else { + graph.in = in + graph.out = out + } + + fmt.Println("graph=", graph) + + // Validate the graph + if err := ff.AVFilterGraph_config(graph.ctx); err != nil { + return nil, errors.Join(err, graph.Close()) + } + + // Return the graph + return graph, nil +} + +// Frees the filter graph and all its resources. +func (g *Graph) Close() error { + // Free the inputs and outputs + if g.in != nil { + ff.AVFilterInOut_list_free(g.in) + g.in = nil + } + if g.out != nil { + ff.AVFilterInOut_list_free(g.out) + g.out = nil + } + if g.ctx != nil { + ff.AVFilterGraph_free(g.ctx) + g.ctx = nil + } + // Return success + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS diff --git a/pkg/avfilter/graph_test.go b/pkg/avfilter/graph_test.go new file mode 100644 index 0000000..1b21f38 --- /dev/null +++ b/pkg/avfilter/graph_test.go @@ -0,0 +1,29 @@ +package avfilter_test + +import ( + "testing" + + // Packages + avfilter "github.com/mutablelogic/go-media/pkg/avfilter" + assert "github.com/stretchr/testify/assert" +) + +func Test_graph_001(t *testing.T) { + assert := assert.New(t) + + graph := avfilter.NewGraph() + assert.NotNil(graph) + assert.NoError(graph.Close()) +} + +func Test_graph_002(t *testing.T) { + assert := assert.New(t) + + graph, err := avfilter.ParseGraph("[a]null[b]") + if !assert.NoError(err) { + t.FailNow() + } + assert.NotNil(graph) + t.Log(graph) + assert.NoError(graph.Close()) +} diff --git a/pkg/avfilter/opt.go b/pkg/avfilter/opt.go new file mode 100644 index 0000000..8382628 --- /dev/null +++ b/pkg/avfilter/opt.go @@ -0,0 +1,31 @@ +package avfilter + +import ( + "github.com/mutablelogic/go-media" + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opt struct { + inputs []*ff.AVFilterInOut + outputs []*ff.AVFilterInOut +} + +type Opt func(*opt) error + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func WithInput(filter, name string) Opt { + return func(o *opt) error { + return media.ErrNotImplemented + } +} + +func WithOutput(filter, name string) Opt { + return func(o *opt) error { + return media.ErrNotImplemented + } +} diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go new file mode 100644 index 0000000..08fd9dd --- /dev/null +++ b/pkg/metadata/metadata.go @@ -0,0 +1,131 @@ +package metadata + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + + // Packages + media "github.com/mutablelogic/go-media" + file "github.com/mutablelogic/go-media/pkg/file" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type meta struct { + Key string `json:"key"` + Value any `json:"value,omitempty"` +} + +type Metadata struct { + meta +} + +var _ media.Metadata = (*Metadata)(nil) + +//////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + MetaArtwork = "artwork" // Metadata key for artwork, set the value as []byte +) + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create a new metadata object +func New(key string, value any) *Metadata { + return &Metadata{ + meta: meta{ + Key: key, + Value: value, + }, + } +} + +//////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m *Metadata) MarshalJSON() ([]byte, error) { + type j struct { + Key string `json:"key"` + Value any `json:"value,omitempty"` + } + if m == nil { + return json.Marshal(nil) + } + return json.Marshal(j{ + Key: m.Key(), + Value: m.Any(), + }) +} + +func (m *Metadata) String() string { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (m *Metadata) Key() string { + return m.meta.Key +} + +// Value returns the value as a string. If the value is a byte slice, it will +// return the mimetype of the byte slice. +func (m *Metadata) Value() string { + if m.meta.Value == nil { + return "" + } + switch v := m.meta.Value.(type) { + case string: + return v + case []byte: + if mimetype, _, err := file.MimeType(v); err == nil { + return mimetype + } else { + return "" + } + default: + return fmt.Sprint(v) + } +} + +// Returns the value as a byte slice +func (m *Metadata) Bytes() []byte { + if m.meta.Value == nil { + return nil + } + switch v := m.meta.Value.(type) { + case []byte: + return v + case string: + return []byte(v) + } + return nil +} + +// Returns the value as an image +func (m *Metadata) Image() image.Image { + if m.meta.Value == nil { + return nil + } + switch v := m.meta.Value.(type) { + case []byte: + if img, _, err := image.Decode(bytes.NewReader(v)); err == nil { + return img + } + } + return nil +} + +// Returns the value as an interface +func (m *Metadata) Any() any { + return m.meta.Value +} diff --git a/pkg/metadata/metadata_test.go b/pkg/metadata/metadata_test.go new file mode 100644 index 0000000..be1b4c9 --- /dev/null +++ b/pkg/metadata/metadata_test.go @@ -0,0 +1,67 @@ +package metadata_test + +import ( + "os" + "testing" + + // Packages + metadata "github.com/mutablelogic/go-media/pkg/metadata" + assert "github.com/stretchr/testify/assert" +) + +func Test_metadata_001(t *testing.T) { + assert := assert.New(t) + + // Create a metadata + metadata := metadata.New("test", "test") + if !assert.NotNil(metadata) { + t.FailNow() + } + assert.Equal("test", metadata.Key()) + assert.Equal("test", metadata.Value()) +} +func Test_metadata_002(t *testing.T) { + assert := assert.New(t) + + data, err := os.ReadFile("../../etc/test/sample.png") + if !assert.NoError(err) { + t.FailNow() + } + + // Create a metadata + meta := metadata.New(metadata.MetaArtwork, data) + if !assert.NotNil(meta) { + t.FailNow() + } + assert.Equal(metadata.MetaArtwork, meta.Key()) + assert.Equal("image/png", meta.Value()) + assert.Equal(data, meta.Bytes()) + + image := meta.Image() + if !assert.NotNil(image) { + t.FailNow() + } +} + +func Test_metadata_003(t *testing.T) { + assert := assert.New(t) + + data, err := os.ReadFile("../../etc/test/sample.jpg") + if !assert.NoError(err) { + t.FailNow() + } + + // Create a metadata + meta := metadata.New(metadata.MetaArtwork, data) + if !assert.NotNil(meta) { + t.FailNow() + } + assert.Equal(metadata.MetaArtwork, meta.Key()) + assert.Equal("image/jpeg", meta.Value()) + assert.Equal(data, meta.Bytes()) + + image := meta.Image() + if !assert.NotNil(image) { + t.FailNow() + } +} diff --git a/sys/ffmpeg71/avcodec.go b/sys/ffmpeg71/avcodec.go index 78f3dcd..93890a6 100644 --- a/sys/ffmpeg71/avcodec.go +++ b/sys/ffmpeg71/avcodec.go @@ -56,11 +56,11 @@ type jsonAVCodecContext struct { PixelFormat AVPixelFormat `json:"pix_fmt,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` - SampleAspectRatio AVRational `json:"sample_aspect_ratio,omitempty"` - Framerate AVRational `json:"framerate,omitempty"` + SampleAspectRatio AVRational `json:"sample_aspect_ratio,omitzero"` + Framerate AVRational `json:"framerate,omitzero"` SampleFormat AVSampleFormat `json:"sample_fmt,omitempty"` SampleRate int `json:"sample_rate,omitempty"` - ChannelLayout AVChannelLayout `json:"channel_layout,omitempty"` + ChannelLayout AVChannelLayout `json:"channel_layout,omitzero"` FrameSize int `json:"frame_size,omitempty"` TimeBase AVRational `json:"time_base,omitempty"` } diff --git a/sys/ffmpeg71/avfilter.go b/sys/ffmpeg71/avfilter.go index 15789b1..05c2bb8 100644 --- a/sys/ffmpeg71/avfilter.go +++ b/sys/ffmpeg71/avfilter.go @@ -22,6 +22,7 @@ type ( AVFilter C.AVFilter AVFilterFlag C.int AVFilterGraph C.AVFilterGraph + AVFilterInOut C.AVFilterInOut ) //////////////////////////////////////////////////////////////////////////////// @@ -48,8 +49,8 @@ func (ctx *AVFilter) MarshalJSON() ([]byte, error) { Name string `json:"name"` Description string `json:"description"` Flags AVFilterFlag `json:"flags,omitzero"` - Inputs uint `json:"inputs,omitempty"` - Outputs uint `json:"outputs,omitempty"` + Inputs uint `json:"num_inputs,omitempty"` + Outputs uint `json:"num_outputs,omitempty"` } if ctx == nil { return json.Marshal(nil) @@ -58,11 +59,65 @@ func (ctx *AVFilter) MarshalJSON() ([]byte, error) { Name: ctx.Name(), Description: ctx.Description(), Flags: ctx.Flags(), - Inputs: ctx.Inputs(), - Outputs: ctx.Outputs(), + Inputs: ctx.NumInputs(), + Outputs: ctx.NumOutputs(), }) } +func (ctx *AVFilterContext) MarshalJSON() ([]byte, error) { + type j struct { + Name string `json:"name"` + Filter *AVFilter `json:"filter"` + Inputs uint `json:"num_inputs,omitempty"` + Outputs uint `json:"num_outputs,omitempty"` + } + if ctx == nil { + return json.Marshal(nil) + } + return json.Marshal(j{ + Name: ctx.Name(), + Filter: ctx.Filter(), + Inputs: ctx.NumInputs(), + Outputs: ctx.NumOutputs(), + }) +} + +func (ctx *AVFilterInOut) MarshalJSON() ([]byte, error) { + type j struct { + Name string `json:"name"` + Filter *AVFilterContext `json:"filter"` + Pad int `json:"pad"` + } + if ctx == nil { + return json.Marshal(nil) + } + return json.Marshal(j{ + Name: ctx.Name(), + Filter: ctx.Filter(), + Pad: ctx.Pad(), + }) +} + +func (ctx *AVFilterGraph) MarshalJSON() ([]byte, error) { + type j struct { + Graph string `json:"graph"` + } + if ctx == nil { + return json.Marshal(nil) + } + return json.Marshal(j{ + Graph: AVFilterGraph_dump(ctx), + }) +} + +func (ctx *AVFilterGraph) String() string { + data, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + func (ctx *AVFilter) String() string { data, err := json.MarshalIndent(ctx, "", " ") if err != nil { @@ -71,6 +126,14 @@ func (ctx *AVFilter) String() string { return string(data) } +func (ctx *AVFilterInOut) String() string { + data, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + //////////////////////////////////////////////////////////////////////////////// // AVFilter @@ -86,14 +149,33 @@ func (c *AVFilter) Flags() AVFilterFlag { return AVFilterFlag(c.flags) } -func (c *AVFilter) Inputs() uint { +func (c *AVFilter) NumInputs() uint { return AVFilter_inputs(c) } -func (c *AVFilter) Outputs() uint { +func (c *AVFilter) NumOutputs() uint { return AVFilter_outputs(c) } +//////////////////////////////////////////////////////////////////////////////// +// AVFilterContext + +func (c *AVFilterContext) Name() string { + return C.GoString(c.name) +} + +func (c *AVFilterContext) Filter() *AVFilter { + return (*AVFilter)(c.filter) +} + +func (c *AVFilterContext) NumInputs() uint { + return uint(c.nb_inputs) +} + +func (c *AVFilterContext) NumOutputs() uint { + return uint(c.nb_outputs) +} + //////////////////////////////////////////////////////////////////////////////// // AVFilterFlag @@ -136,3 +218,26 @@ func (v AVFilterFlag) FlagString() string { return fmt.Sprintf("AVFilterFlag(0x%08X)", uint32(v)) } } + +//////////////////////////////////////////////////////////////////////////////// +// AVFilterInOut + +func (c *AVFilterInOut) Name() string { + return C.GoString(c.name) +} + +func (c *AVFilterInOut) Filter() *AVFilterContext { + return (*AVFilterContext)(c.filter_ctx) +} + +func (c *AVFilterInOut) Pad() int { + return int(c.pad_idx) +} + +func (c *AVFilterInOut) Next() *AVFilterInOut { + return (*AVFilterInOut)(c.next) +} + +func (c *AVFilterInOut) SetNext(next *AVFilterInOut) { + c.next = (*C.AVFilterInOut)(next) +} diff --git a/sys/ffmpeg71/avfilter_graph.go b/sys/ffmpeg71/avfilter_graph.go index 28e71cf..f987e67 100644 --- a/sys/ffmpeg71/avfilter_graph.go +++ b/sys/ffmpeg71/avfilter_graph.go @@ -1,5 +1,10 @@ package ffmpeg +import ( + "fmt" + "unsafe" +) + //////////////////////////////////////////////////////////////////////////////// // CGO @@ -8,10 +13,6 @@ package ffmpeg #include */ import "C" -import ( - "fmt" - "unsafe" -) //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -25,6 +26,33 @@ func AVFilterGraph_free(graph *AVFilterGraph) { C.avfilter_graph_free(&ctx) } +// Check validity and configure all the links and formats in the graph. +func AVFilterGraph_config(graph *AVFilterGraph) error { + if err := AVError(C.avfilter_graph_config((*C.AVFilterGraph)(graph), nil)); err != 0 { + return fmt.Errorf("avfilter_graph_config: %w", err) + } + return nil +} + +// Dump the graph out. +func AVFilterGraph_dump(graph *AVFilterGraph) string { + if graph == nil { + return "" + } + + // Check if the graph is valid + if err := AVFilterGraph_config(graph); err != nil { + return err.Error() + } + + // Dump the graph + cStr := C.avfilter_graph_dump((*C.AVFilterGraph)(graph), nil) + defer C.free(unsafe.Pointer(cStr)) + + // Return the string + return C.GoString(cStr) +} + // Allocates and initializes a filter in a single step. // The filter instance is created from the filter and inited with the parameter args. func AVFilterGraph_create_filter(graph *AVFilterGraph, filter *AVFilter, name, args string) (*AVFilterContext, error) { @@ -38,3 +66,24 @@ func AVFilterGraph_create_filter(graph *AVFilterGraph, filter *AVFilter, name, a } return (*AVFilterContext)(ctx), nil } + +// Add a graph described by a string to a graph,returning inputs and outputs. Will return an error +// if not all inputs and outputs are specified. The inputs and outputs should be freed +// with AVFilterInOut_free() when no longer needed. +func AVFilterGraph_parse(graph *AVFilterGraph, filters string) ([]*AVFilterInOut, []*AVFilterInOut, error) { + var ins, outs *AVFilterInOut + + // Allocate cstring for the filter spec + cFilters := C.CString(filters) + defer C.free(unsafe.Pointer(cFilters)) + + // First attempt to parse the filter graph: returns any inputs and outputs + if err := AVError(C.avfilter_graph_parse_ptr((*C.AVFilterGraph)(graph), cFilters, (**C.AVFilterInOut)(unsafe.Pointer(&ins)), (**C.AVFilterInOut)(unsafe.Pointer(&outs)), nil)); err != 0 { + AVFilterInOut_free(ins) + AVFilterInOut_free(outs) + return nil, nil, fmt.Errorf("avfilter_graph_parse: %w", err) + } + + // Return success + return AVFilterInOut_list(ins), AVFilterInOut_list(outs), nil +} diff --git a/sys/ffmpeg71/avfilter_graph_test.go b/sys/ffmpeg71/avfilter_graph_test.go index 5c428bf..be7430c 100644 --- a/sys/ffmpeg71/avfilter_graph_test.go +++ b/sys/ffmpeg71/avfilter_graph_test.go @@ -26,9 +26,33 @@ func Test_avfilter_graph_001(t *testing.T) { assert.NotNil(filter) // Create a filter context - ctx, err := ff.AVFilterGraph_create_filter(graph, filter, "null", "") + ctx, err := ff.AVFilterGraph_create_filter(graph, filter, "zzz", "") assert.NoError(err) assert.NotNil(ctx) // We don't need to free the filter context, as it is freed when the graph is freed } + +func Test_avfilter_graph_002(t *testing.T) { + assert := assert.New(t) + graph := ff.AVFilterGraph_alloc() + assert.NotNil(graph) + defer ff.AVFilterGraph_free(graph) + + // Parse a filter graph, and return the inputs and outputs, which should be + // freed when the graph is freed + in, out, err := ff.AVFilterGraph_parse(graph, "[a]null[b]") + assert.NoError(err) + defer ff.AVFilterInOut_list_free(in) + defer ff.AVFilterInOut_list_free(out) + + t.Log("graph=", graph) + t.Log("in=", in) + t.Log("out=", out) + + // One input and one output + assert.Len(in, 1) + assert.Equal("a", in[0].Name()) + assert.Len(out, 1) + assert.Equal("b", out[0].Name()) +} diff --git a/sys/ffmpeg71/avfilter_inout.go b/sys/ffmpeg71/avfilter_inout.go new file mode 100644 index 0000000..7ad3d0a --- /dev/null +++ b/sys/ffmpeg71/avfilter_inout.go @@ -0,0 +1,69 @@ +package ffmpeg + +//////////////////////////////////////////////////////////////////////////////// +// CGO + +/* +#cgo pkg-config: libavfilter +#include +*/ +import "C" + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Allocate a single AVFilterInOut entry, with name, filter context and pad index. +func AVFilterInOut_alloc(name string, filter *AVFilterContext, pad int) *AVFilterInOut { + inout := (*C.AVFilterInOut)(C.avfilter_inout_alloc()) + if inout == nil { + return nil + } + inout.name = C.CString(name) + inout.filter_ctx = (*C.AVFilterContext)(filter) + inout.pad_idx = C.int(pad) + inout.next = nil + return (*AVFilterInOut)(inout) +} + +// Free a single AVFilterInOut entry, including its name. +func AVFilterInOut_free(inout *AVFilterInOut) { + ctx := (*C.AVFilterInOut)(inout) + C.avfilter_inout_free(&ctx) +} + +// Link an array of AVFilterInOut entries together, and return the first entry. +// If the array is empty, or any entry is nil, nil is returned. +func AVFilterInOut_link(inout ...*AVFilterInOut) *AVFilterInOut { + if len(inout) == 0 { + return nil + } + for i := 0; i < len(inout)-1; i++ { + if inout[i] == nil { + return nil + } + inout[i].SetNext(inout[i+1]) + } + inout[len(inout)-1].SetNext(nil) + return inout[0] +} + +// Return an array of AVFilterInOut entries, given the first entry. +// Returns nil if the first entry is nil. +func AVFilterInOut_list(head *AVFilterInOut) []*AVFilterInOut { + if head == nil { + return nil + } + var result []*AVFilterInOut + for inout := head; inout != nil; inout = inout.Next() { + result = append(result, inout) + } + return result +} + +// Free an array of AVFilterInOut entries, given the first entry. +func AVFilterInOut_list_free(list []*AVFilterInOut) { + if len(list) == 0 { + return + } + AVFilterInOut_free(list[0]) +} diff --git a/sys/ffmpeg71/avfilter_inout_test.go b/sys/ffmpeg71/avfilter_inout_test.go new file mode 100644 index 0000000..c00e595 --- /dev/null +++ b/sys/ffmpeg71/avfilter_inout_test.go @@ -0,0 +1,36 @@ +package ffmpeg_test + +import ( + "testing" + + // Packages + ff "github.com/mutablelogic/go-media/sys/ffmpeg71" + assert "github.com/stretchr/testify/assert" +) + +func Test_avfilter_inout_000(t *testing.T) { + assert := assert.New(t) + + inout := ff.AVFilterInOut_alloc("in", nil, 0) + assert.NotNil(inout) + assert.Equal("in", inout.Name()) + assert.Equal(0, inout.Pad()) + assert.Nil(inout.Filter()) + assert.Nil(inout.Next()) + + t.Log("inout=", inout) + + ff.AVFilterInOut_free(inout) +} + +func Test_avfilter_inout_001(t *testing.T) { + assert := assert.New(t) + + head := ff.AVFilterInOut_link(ff.AVFilterInOut_alloc("in1", nil, 0), ff.AVFilterInOut_alloc("in2", nil, 0)) + assert.NotNil(head) + + arr := ff.AVFilterInOut_list(head) + assert.Equal(2, len(arr)) + + t.Log("arr=", arr) +}