diff --git a/.github/workflows/on_pull_request_merge.yaml b/.github/workflows/on_pull_request_merge.yaml index bb85fc6..0d65661 100644 --- a/.github/workflows/on_pull_request_merge.yaml +++ b/.github/workflows/on_pull_request_merge.yaml @@ -19,6 +19,8 @@ jobs: go-version: ${{ matrix.go-version }} - name: Run tests run: | - sudo apt install -y libavcodec-dev libavdevice-dev libavfilter-dev libavutil-dev libswscale-dev libswresample-dev libchromaprint-dev + sudo apt install -y libavcodec-dev libavdevice-dev libavfilter-dev libavutil-dev libswscale-dev libswresample-dev + sudo apt install -y libchromaprint-dev + sudo apt install -y libsdl2-dev make container-test diff --git a/go.mod b/go.mod index 946a4a9..1d62c32 100755 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/llgcode/draw2d v0.0.0-20240627062922-0ed1ff131195 github.com/mutablelogic/go-client v1.0.8 github.com/stretchr/testify v1.9.0 + github.com/veandco/go-sdl2 v0.4.40 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 ) diff --git a/manager.go b/manager.go index 96d9997..4c18db0 100644 --- a/manager.go +++ b/manager.go @@ -16,7 +16,8 @@ package media // } // // Various options are available to control the manager, for -// logging and affecting decoding. +// logging and affecting decoding, that can be applied when +// creating the manager by passing them as arguments. // // Only one manager can be created. If NewManager is called // a second time, the previously created manager is returned, @@ -46,33 +47,11 @@ type Manager interface { // of the caller to also close the writer when done. //Write(io.Writer, Format, []Metadata, ...Parameters) (Media, 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 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 all supported channel layouts - //ChannelLayouts() []Metadata - - // Return all supported sample formats - //SampleFormats() []Metadata - - // Return all supported pixel formats - //PixelFormats() []Metadata - - // Return all supported codecs - //Codecs() []Metadata - // Return audio parameters for encoding // ChannelLayout, SampleFormat, Samplerate //AudioParameters(string, string, int) (Parameters, error) @@ -89,6 +68,32 @@ 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 all supported sample formats + SampleFormats() []Metadata + + // Return all supported pixel formats + PixelFormats() []Metadata + + // Return standard channel layouts which can be used for audio, + // with the number of channels provided. If no channels are provided, + // then all standard channel layouts are returned. + ChannelLayouts() []Metadata + + // Return all supported codecs, of a specific type or all + // if ANY is used. If any names is provided, then only the codecs + // with those names are returned. + Codecs(Type, ...string) []Metadata + // Return version information for the media manager as a set of // metadata Version() []Metadata diff --git a/metadata.go b/metadata.go index da741a0..b41d36a 100644 --- a/metadata.go +++ b/metadata.go @@ -25,4 +25,7 @@ type Metadata interface { // Returns the value as an image Image() image.Image + + // Returns the value as an interface + Any() any } diff --git a/pkg/ffmpeg/channellayout.go b/pkg/ffmpeg/channellayout.go new file mode 100644 index 0000000..45c3a20 --- /dev/null +++ b/pkg/ffmpeg/channellayout.go @@ -0,0 +1,135 @@ +package ffmpeg + +import ( + "encoding/json" + + // Packages + ff "github.com/mutablelogic/go-media/sys/ffmpeg61" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ( + ChannelLayout ff.AVChannelLayout + Channel ff.AVChannel +) + +type jsonChannelLayout struct { + Name string `json:"name"` + NumChannels int `json:"num_channels"` + Order string `json:"order"` + Channels []*Channel `json:"channels"` +} + +type jsonChannel struct { + Index int `json:"index"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func newChannelLayout(channellayout *ff.AVChannelLayout) *ChannelLayout { + if !ff.AVUtil_channel_layout_check(channellayout) { + return nil + } + return (*ChannelLayout)(channellayout) +} + +func newChannel(channel ff.AVChannel) *Channel { + if channel == ff.AV_CHAN_NONE { + return nil + } + return (*Channel)(&channel) +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (ch *ChannelLayout) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonChannelLayout{ + Name: ch.Name(), + NumChannels: ch.NumChannels(), + Order: ch.Order(), + Channels: ch.Channels(), + }) +} + +func (ch *Channel) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonChannel{ + Name: ch.Name(), + Description: ch.Description(), + }) +} + +func (ch *ChannelLayout) String() string { + data, _ := json.MarshalIndent(ch, "", " ") + return string(data) +} + +func (ch *Channel) String() string { + data, _ := json.MarshalIndent(ch, "", " ") + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PROPERTIES - CHANNEL LAYOUT + +func (ch *ChannelLayout) Name() string { + if desc, err := ff.AVUtil_channel_layout_describe((*ff.AVChannelLayout)(ch)); err != nil { + return "" + } else { + return desc + } +} + +func (ch *ChannelLayout) NumChannels() int { + return ff.AVUtil_get_channel_layout_nb_channels((*ff.AVChannelLayout)(ch)) +} + +func (ch *ChannelLayout) Channels() []*Channel { + var result []*Channel + for i := 0; i < ch.NumChannels(); i++ { + channel := ff.AVUtil_channel_layout_channel_from_index((*ff.AVChannelLayout)(ch), i) + if channel != ff.AV_CHAN_NONE { + result = append(result, newChannel(channel)) + } + } + return result +} + +func (ch *ChannelLayout) Order() string { + order := (*ff.AVChannelLayout)(ch).Order() + switch order { + case ff.AV_CHANNEL_ORDER_UNSPEC: + return "unspecified" + case ff.AV_CHANNEL_ORDER_NATIVE: + return "native" + case ff.AV_CHANNEL_ORDER_CUSTOM: + return "custom" + case ff.AV_CHANNEL_ORDER_AMBISONIC: + return "ambisonic" + } + return order.String() +} + +/////////////////////////////////////////////////////////////////////////////// +// PROPERTIES - CHANNEL + +func (ch *Channel) Name() string { + if desc, err := ff.AVUtil_channel_name((ff.AVChannel)(*ch)); err != nil { + return "unknown" + } else { + return desc + } +} + +func (ch *Channel) Description() string { + if desc, err := ff.AVUtil_channel_description((ff.AVChannel)(*ch)); err != nil { + return "" + } else { + return desc + } +} diff --git a/pkg/ffmpeg/channellayout_test.go b/pkg/ffmpeg/channellayout_test.go new file mode 100644 index 0000000..41d17b3 --- /dev/null +++ b/pkg/ffmpeg/channellayout_test.go @@ -0,0 +1,22 @@ +package ffmpeg_test + +import ( + "testing" + + // Packages + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + assert "github.com/stretchr/testify/assert" +) + +func Test_channellayout_001(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, format := range manager.ChannelLayouts() { + t.Logf("%v", format) + } +} diff --git a/pkg/ffmpeg/codec.go b/pkg/ffmpeg/codec.go new file mode 100644 index 0000000..7f8e8f3 --- /dev/null +++ b/pkg/ffmpeg/codec.go @@ -0,0 +1,114 @@ +package ffmpeg + +import ( + "encoding/json" + "sort" + + // Packages + media "github.com/mutablelogic/go-media" + ff "github.com/mutablelogic/go-media/sys/ffmpeg61" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Codec ff.AVCodec + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func newCodec(codec *ff.AVCodec) *Codec { + return (*Codec)(codec) +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (codec *Codec) MarshalJSON() ([]byte, error) { + return (*ff.AVCodec)(codec).MarshalJSON() +} + +func (codec *Codec) String() string { + data, _ := json.MarshalIndent((*ff.AVCodec)(codec), "", " ") + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return the type of codec +func (codec *Codec) Type() media.Type { + switch (*ff.AVCodec)(codec).Type() { + case ff.AVMEDIA_TYPE_AUDIO: + return media.AUDIO + case ff.AVMEDIA_TYPE_VIDEO: + return media.VIDEO + case ff.AVMEDIA_TYPE_SUBTITLE: + return media.SUBTITLE + } + return media.NONE +} + +// The name the codec is referred to by +func (codec *Codec) Name() string { + return (*ff.AVCodec)(codec).Name() +} + +// The description of the codec +func (codec *Codec) Description() string { + return (*ff.AVCodec)(codec).LongName() +} + +// Pixel formats supported by the codec. This is only valid for video codecs. +// The first pixel format is the default. +func (codec *Codec) PixelFormats() []string { + pixfmts := (*ff.AVCodec)(codec).PixelFormats() + result := make([]string, len(pixfmts)) + for i, pixfmt := range pixfmts { + result[i] = ff.AVUtil_get_pix_fmt_name(pixfmt) + } + return result +} + +// Sample formats supported by the codec. This is only valid for audio codecs. +// The first sample format is the default. +func (codec *Codec) SampleFormats() []string { + samplefmts := (*ff.AVCodec)(codec).SampleFormats() + result := make([]string, len(samplefmts)) + for i, samplefmt := range samplefmts { + result[i] = ff.AVUtil_get_sample_fmt_name(samplefmt) + } + return result +} + +// Sample rates supported by the codec. This is only valid for audio codecs. +// The first sample rate is the highest, sort the list in reverse order. +func (codec *Codec) SampleRates() []int { + samplerates := (*ff.AVCodec)(codec).SupportedSamplerates() + sort.Sort(sort.Reverse(sort.IntSlice(samplerates))) + return samplerates +} + +// Channel layouts supported by the codec. This is only valid for audio codecs. +func (codec *Codec) ChannelLayouts() []string { + chlayouts := (*ff.AVCodec)(codec).ChannelLayouts() + result := make([]string, 0, len(chlayouts)) + for _, chlayout := range chlayouts { + name, err := ff.AVUtil_channel_layout_describe(&chlayout) + if err != nil { + continue + } + result = append(result, name) + } + return result +} + +// Profiles supported by the codec. This is only valid for video codecs. +func (codec *Codec) Profiles() []string { + profiles := (*ff.AVCodec)(codec).Profiles() + result := make([]string, len(profiles)) + for i, profile := range profiles { + result[i] = profile.Name() + } + return result +} diff --git a/pkg/ffmpeg/codec_test.go b/pkg/ffmpeg/codec_test.go new file mode 100644 index 0000000..1a622de --- /dev/null +++ b/pkg/ffmpeg/codec_test.go @@ -0,0 +1,86 @@ +package ffmpeg_test + +import ( + "testing" + + // Packages + media "github.com/mutablelogic/go-media" + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + assert "github.com/stretchr/testify/assert" +) + +func Test_codec_001(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, codec := range manager.Codecs(media.ANY) { + t.Logf("%v", codec) + } +} + +func Test_codec_002(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, codec := range manager.Codecs(media.VIDEO) { + assert.Equal(media.VIDEO, codec.Any().(*ffmpeg.Codec).Type()) + t.Logf("%v", codec) + } + + for _, codec := range manager.Codecs(media.AUDIO) { + assert.Equal(media.AUDIO, codec.Any().(*ffmpeg.Codec).Type()) + t.Logf("%v", codec) + } + + for _, codec := range manager.Codecs(media.SUBTITLE) { + assert.Equal(media.SUBTITLE, codec.Any().(*ffmpeg.Codec).Type()) + t.Logf("%v", codec) + } +} + +func Test_codec_003(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, codec := range manager.Codecs(media.ANY, "h264") { + assert.Equal("h264", codec.Any().(*ffmpeg.Codec).Name()) + t.Logf("%v", codec) + } +} + +func Test_codec_004(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, meta := range manager.Codecs(media.ANY) { + codec := meta.Any().(*ffmpeg.Codec) + t.Logf("NAME %q", codec.Name()) + t.Logf(" TYPE %q", codec.Type()) + t.Logf(" DESCRIPTION %q", codec.Description()) + switch codec.Type() { + case media.VIDEO: + t.Logf(" PIXEL FORMATS %q", codec.PixelFormats()) + t.Logf(" PROFILES %q", codec.Profiles()) + case media.AUDIO: + t.Logf(" SAMPLE FORMATS %q", codec.SampleFormats()) + t.Logf(" SAMPLE RATES %q", codec.SampleRates()) + t.Logf(" CH LAYOUTS %q", codec.ChannelLayouts()) + } + } +} diff --git a/pkg/ffmpeg/decoder.go b/pkg/ffmpeg/decoder.go index 73d0efd..d26d7db 100644 --- a/pkg/ffmpeg/decoder.go +++ b/pkg/ffmpeg/decoder.go @@ -16,7 +16,7 @@ import ( type Decoder struct { stream int codec *ff.AVCodecContext - dest *Par // Destination parameters + par *Par // Destination parameters re *Re // Resample/resize timeBase ff.AVRational // Timebase for the stream frame *ff.AVFrame // Destination frame @@ -29,7 +29,7 @@ type Decoder struct { func NewDecoder(stream *ff.AVStream, dest *Par, force bool) (*Decoder, error) { decoder := new(Decoder) decoder.stream = stream.Id() - decoder.dest = dest + decoder.par = dest decoder.timeBase = stream.TimeBase() // Create a frame for decoder output - before resize/resample @@ -152,12 +152,9 @@ func (d *Decoder) decode(packet *ff.AVPacket, fn DecoderFrameFn) error { dest = (*Frame)(d.frame) } - // TODO: Modify Pts? - // What else do we need to copy across? - fmt.Println("TODO", d.timeBase, dest.TimeBase(), ff.AVTimestamp(dest.Pts())) - if dest.Pts() == PTS_UNDEFINED { - (*ff.AVFrame)(dest).SetPts(d.frame.Pts()) - } + // Copy across the timebase and pts + (*ff.AVFrame)(dest).SetPts(d.frame.Pts()) + (*ff.AVFrame)(dest).SetTimeBase(d.timeBase) // Pass back to the caller if err := fn(d.stream, dest); errors.Is(err, io.EOF) { diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go index 5b6da30..21aa27b 100644 --- a/pkg/ffmpeg/encoder.go +++ b/pkg/ffmpeg/encoder.go @@ -29,14 +29,6 @@ type Encoder struct { next_pts int64 } -// EncoderFrameFn 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 EncoderFrameFn func(int) (*Frame, error) - -// EncoderPacketFn is a function which is called for each packet encoded, with -// the stream timebase. -type EncoderPacketFn func(*Packet) error - //////////////////////////////////////////////////////////////////////////////// // LIFECYCLE diff --git a/pkg/ffmpeg/frame.go b/pkg/ffmpeg/frame.go index 4e64ec1..e843614 100644 --- a/pkg/ffmpeg/frame.go +++ b/pkg/ffmpeg/frame.go @@ -19,6 +19,7 @@ type Frame ff.AVFrame const ( PTS_UNDEFINED = ff.AV_NOPTS_VALUE + TS_UNDEFINED = -1.0 ) /////////////////////////////////////////////////////////////////////////////// @@ -92,6 +93,42 @@ func (frame *Frame) MakeWritable() error { return ff.AVUtil_frame_make_writable((*ff.AVFrame)(frame)) } +// Make a copy of the frame, which should be released by the caller +func (frame *Frame) Copy() (*Frame, error) { + copy := ff.AVUtil_frame_alloc() + if copy == nil { + return nil, errors.New("failed to allocate frame") + } + switch frame.Type() { + case media.AUDIO: + copy.SetSampleFormat(frame.SampleFormat()) + copy.SetChannelLayout(frame.ChannelLayout()) + copy.SetSampleRate(frame.SampleRate()) + copy.SetNumSamples(frame.NumSamples()) + case media.VIDEO: + copy.SetPixFmt(frame.PixelFormat()) + copy.SetWidth(frame.Width()) + copy.SetHeight(frame.Height()) + copy.SetSampleAspectRatio(frame.SampleAspectRatio()) + default: + 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 err := ff.AVUtil_frame_copy_props(copy, (*ff.AVFrame)(frame)); err != nil { + ff.AVUtil_frame_free(copy) + return nil, err + } + return (*Frame)(copy), nil +} + // Unreference frame buffers func (frame *Frame) Unref() { ff.AVUtil_frame_unref((*ff.AVFrame)(frame)) @@ -199,17 +236,17 @@ func (frame *Frame) IncPts(v int64) { (*ff.AVFrame)(frame).SetPts((*ff.AVFrame)(frame).Pts() + v) } -// Return the timestamp in seconds, or PTS_UNDEFINED if the timestamp +// Return the timestamp in seconds, or TS_UNDEFINED if the timestamp // is undefined or timebase is not set func (frame *Frame) Ts() float64 { ctx := (*ff.AVFrame)(frame) pts := ctx.Pts() if pts == ff.AV_NOPTS_VALUE { - return PTS_UNDEFINED + return TS_UNDEFINED } tb := ctx.TimeBase() if tb.Num() == 0 || tb.Den() == 0 { - return PTS_UNDEFINED + return TS_UNDEFINED } return ff.AVUtil_rational_q2d(tb) * float64(pts) } diff --git a/pkg/ffmpeg/frame_test.go b/pkg/ffmpeg/frame_test.go index c4fba50..fc684ab 100644 --- a/pkg/ffmpeg/frame_test.go +++ b/pkg/ffmpeg/frame_test.go @@ -3,6 +3,7 @@ package ffmpeg_test import ( "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" diff --git a/pkg/ffmpeg/image_test.go b/pkg/ffmpeg/image_test.go index 75399f3..4bcd9f5 100644 --- a/pkg/ffmpeg/image_test.go +++ b/pkg/ffmpeg/image_test.go @@ -8,6 +8,7 @@ import ( "os" "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" imagex "github.com/mutablelogic/go-media/pkg/image" assert "github.com/stretchr/testify/assert" diff --git a/pkg/ffmpeg/manager.go b/pkg/ffmpeg/manager.go index 4d8e6c4..328d5cb 100644 --- a/pkg/ffmpeg/manager.go +++ b/pkg/ffmpeg/manager.go @@ -1,10 +1,11 @@ package ffmpeg import ( + "slices" // Packages media "github.com/mutablelogic/go-media" - "github.com/mutablelogic/go-media/pkg/version" + version "github.com/mutablelogic/go-media/pkg/version" ff "github.com/mutablelogic/go-media/sys/ffmpeg61" ) @@ -96,16 +97,95 @@ func (manager *Manager) NewReader(r io.Reader, format media.Format, opts ...stri } */ -/* -func (manager *Manager) Transcode(context,output_writer,input_reader or file,input_mapping_function) { - // 1. Read the input and detect the streams - // 2. Make a mapping to output streams - // 3. Create an output writer or file, with the mapped streams - // 4. Create one goroutine which reads the input and passes frames to a channel - // 5. Create a second goroutine which reads the channel and writes to the output - // 6. When EOF on the input or context is cancelled, then stop +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - CODECS, PIXEL FORMATS, SAMPLE FORMATS AND CHANNEL +// LAYOUTS + +// Return all supported sample formats +func (manager *Manager) SampleFormats() []media.Metadata { + var result []media.Metadata + var opaque uintptr + for { + samplefmt := ff.AVUtil_next_sample_fmt(&opaque) + if samplefmt == ff.AV_SAMPLE_FMT_NONE { + break + } + if sampleformat := newSampleFormat(samplefmt); sampleformat != nil { + result = append(result, NewMetadata(sampleformat.Name(), sampleformat)) + } + } + return result +} + +// Return all supported pixel formats +func (manager *Manager) PixelFormats() []media.Metadata { + var result []media.Metadata + var opaque uintptr + for { + pixfmt := ff.AVUtil_next_pixel_fmt(&opaque) + if pixfmt == ff.AV_PIX_FMT_NONE { + break + } + if pixelformat := newPixelFormat(pixfmt); pixelformat != nil { + result = append(result, NewMetadata(pixelformat.Name(), pixelformat)) + } + } + return result +} + +// Return standard channel layouts which can be used for audio +func (manager *Manager) ChannelLayouts() []media.Metadata { + var result []media.Metadata + var iter uintptr + for { + ch := ff.AVUtil_channel_layout_standard(&iter) + if ch == nil { + break + } + if channellayout := newChannelLayout(ch); channellayout != nil { + result = append(result, NewMetadata(channellayout.Name(), channellayout)) + } + } + return result +} + +// Return all supported codecs, of a specific type or all +// if ANY is used. If any names is provided, then only the codecs +// with those names are returned. Codecs can be AUDIO, VIDEO and +// SUBTITLE +func (manager *Manager) Codecs(t media.Type, name ...string) []media.Metadata { + var iter uintptr + + // Filter to match codecs + codecMatchesFilter := func(codec *Codec, t media.Type, names ...string) bool { + if codec == nil { + return false + } + if !(t == media.ANY || codec.Type().Is(t)) { + return false + } + if len(name) > 0 && !slices.Contains(names, codec.Name()) { + return false + } + return true + } + + // Iterate over codecs + result := []media.Metadata{} + for { + codec := ff.AVCodec_iterate(&iter) + if codec == nil { + break + } + codec_ := newCodec(codec) + if codecMatchesFilter(codec_, t, name...) { + result = append(result, NewMetadata(codec_.Name(), codec_)) + } + } + + // Return matched codecs + return result } -*/ /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS - VERSION diff --git a/pkg/ffmpeg/manager_test.go b/pkg/ffmpeg/manager_test.go index ae4ae10..9a71458 100644 --- a/pkg/ffmpeg/manager_test.go +++ b/pkg/ffmpeg/manager_test.go @@ -3,6 +3,7 @@ package ffmpeg_test import ( "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" ) diff --git a/pkg/ffmpeg/metadata.go b/pkg/ffmpeg/metadata.go index 65b682a..097b9d3 100644 --- a/pkg/ffmpeg/metadata.go +++ b/pkg/ffmpeg/metadata.go @@ -105,3 +105,8 @@ func (m *Metadata) Image() image.Image { } return nil } + +// Returns the value as an interface +func (m *Metadata) Any() any { + return m.meta.Value +} diff --git a/pkg/ffmpeg/metadata_test.go b/pkg/ffmpeg/metadata_test.go index e1f136b..328640f 100644 --- a/pkg/ffmpeg/metadata_test.go +++ b/pkg/ffmpeg/metadata_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" ) diff --git a/pkg/ffmpeg/packet.go b/pkg/ffmpeg/packet.go index 771b6a7..0c3f318 100644 --- a/pkg/ffmpeg/packet.go +++ b/pkg/ffmpeg/packet.go @@ -23,16 +23,16 @@ func (packet *Packet) String() string { /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -// Return the timestamp in seconds, or PTS_UNDEFINED if the timestamp +// Return the timestamp in seconds, or TS_UNDEFINED if the timestamp // is undefined or timebase is not set func (packet *Packet) Ts() float64 { if packet == nil { - return PTS_UNDEFINED + return TS_UNDEFINED } if pts := (*ff.AVPacket)(packet).Pts(); pts == ff.AV_NOPTS_VALUE { - return PTS_UNDEFINED + return TS_UNDEFINED } else if tb := (*ff.AVPacket)(packet).TimeBase(); tb.Num() == 0 || tb.Den() == 0 { - return PTS_UNDEFINED + return TS_UNDEFINED } else { return ff.AVUtil_rational_q2d(tb) * float64(pts) } diff --git a/pkg/ffmpeg/par_test.go b/pkg/ffmpeg/par_test.go index 52a06aa..35b8288 100644 --- a/pkg/ffmpeg/par_test.go +++ b/pkg/ffmpeg/par_test.go @@ -3,6 +3,7 @@ package ffmpeg_test import ( "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" ) diff --git a/pkg/ffmpeg/pixelformat.go b/pkg/ffmpeg/pixelformat.go new file mode 100644 index 0000000..24ad003 --- /dev/null +++ b/pkg/ffmpeg/pixelformat.go @@ -0,0 +1,63 @@ +package ffmpeg + +import ( + "encoding/json" + + // Packages + ff "github.com/mutablelogic/go-media/sys/ffmpeg61" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type PixelFormat ff.AVPixelFormat + +type jsonPixelFormat struct { + Name string `json:"name"` + IsPlanar bool `json:"is_planar"` + NumPlanes int `json:"num_planes,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func newPixelFormat(pixfmt ff.AVPixelFormat) *PixelFormat { + if pixfmt == ff.AV_PIX_FMT_NONE { + return nil + } else if name := ff.AVUtil_get_pix_fmt_name(pixfmt); name == "" { + return nil + } else { + return (*PixelFormat)(&pixfmt) + } +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (pixfmt *PixelFormat) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonPixelFormat{ + Name: pixfmt.Name(), + IsPlanar: pixfmt.IsPlanar(), + NumPlanes: pixfmt.NumPlanes(), + }) +} + +func (pixfmt *PixelFormat) String() string { + data, _ := json.MarshalIndent(pixfmt, "", " ") + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PROPERTIES + +func (pixfmt *PixelFormat) Name() string { + return ff.AVUtil_get_pix_fmt_name(ff.AVPixelFormat(*pixfmt)) +} + +func (pixfmt *PixelFormat) IsPlanar() bool { + return ff.AVUtil_pix_fmt_count_planes(ff.AVPixelFormat(*pixfmt)) > 1 +} + +func (pixfmt *PixelFormat) NumPlanes() int { + return ff.AVUtil_pix_fmt_count_planes(ff.AVPixelFormat(*pixfmt)) +} diff --git a/pkg/ffmpeg/pixelformat_test.go b/pkg/ffmpeg/pixelformat_test.go new file mode 100644 index 0000000..22e8064 --- /dev/null +++ b/pkg/ffmpeg/pixelformat_test.go @@ -0,0 +1,22 @@ +package ffmpeg_test + +import ( + "testing" + + // Packages + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + assert "github.com/stretchr/testify/assert" +) + +func Test_pixelformat_001(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, format := range manager.PixelFormats() { + t.Logf("%v", format) + } +} diff --git a/pkg/ffmpeg/re.go b/pkg/ffmpeg/re.go index d3d96fd..dc06c3b 100644 --- a/pkg/ffmpeg/re.go +++ b/pkg/ffmpeg/re.go @@ -22,6 +22,9 @@ type Re struct { //////////////////////////////////////////////////////////////////////////////// // LIFECYCLE +// Return a new resampler or rescaler, with the destination parameters. If +// force is true, then the resampler will always resample, even if the +// destination parameters are the same as the source parameters. func NewRe(par *Par, force bool) (*Re, error) { re := new(Re) re.t = par.Type() @@ -46,6 +49,7 @@ func NewRe(par *Par, force bool) (*Re, error) { return re, nil } +// Release resources func (re *Re) Close() error { var result error if re.audio != nil { @@ -63,6 +67,7 @@ func (re *Re) Close() error { //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS +// Resample or rescale the source frame and return the destination frame func (re *Re) Frame(src *Frame) (*Frame, error) { // Check type - if not flush if src != nil { diff --git a/pkg/ffmpeg/re_test.go b/pkg/ffmpeg/re_test.go index eb70a0f..26cd546 100644 --- a/pkg/ffmpeg/re_test.go +++ b/pkg/ffmpeg/re_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + // Packages media "github.com/mutablelogic/go-media" ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" diff --git a/pkg/ffmpeg/reader.go b/pkg/ffmpeg/reader.go index fb13813..ffed98e 100644 --- a/pkg/ffmpeg/reader.go +++ b/pkg/ffmpeg/reader.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "slices" "strings" + "sync" "time" // Packages @@ -179,7 +181,11 @@ func (r *Reader) BestStream(t media.Type) int { switch { case t.Is(media.VIDEO): if stream, _, err := ff.AVFormat_find_best_stream(r.input, ff.AVMEDIA_TYPE_VIDEO, -1, -1); err == nil { - return r.input.Stream(stream).Id() + // Only return if this doesn't have a disposition - so we don't select artwork, for example + disposition := r.input.Stream(stream).Disposition() + if disposition == 0 || disposition.Is(ff.AV_DISPOSITION_DEFAULT) { + return r.input.Stream(stream).Id() + } } case t.Is(media.AUDIO): if stream, _, err := ff.AVFormat_find_best_stream(r.input, ff.AVMEDIA_TYPE_AUDIO, -1, -1); err == nil { @@ -230,11 +236,102 @@ func (r *Reader) Metadata(keys ...string) []*Metadata { // returning an error or io.EOF. The latter will end the decoding process early but // will not return an error. func (r *Reader) Decode(ctx context.Context, mapfn DecoderMapFunc, decodefn DecoderFrameFn) error { - decoders := make(map[int]*Decoder, r.input.NumStreams()) + // Map streams to decoders + decoders, err := r.mapStreams(mapfn) + if err != nil { + return err + } + defer decoders.Close() + + // Do the decoding + return r.decode(ctx, decoders, decodefn) +} + +// Transcode the media stream to a writer +// 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) + if err != nil { + return err + } + defer decoders.Close() + + // Add streams to the output + for _, decoder := range decoders { + opt = append(opt, OptStream(decoder.stream, decoder.par)) + } + + // Create an output + output, err := NewWriter(w, opt...) + if err != nil { + return err + } + defer output.Close() + + // One go-routine for decoding, one for encoding + var wg sync.WaitGroup + var result error + + // Make a channel for transcoding frames. The decoder should + // be ahead of the encoder, so there is probably no need to + // create a buffered channel. + ch := make(chan *Frame) + + // Decoding + wg.Add(1) + go func() { + defer wg.Done() + if err := r.decode(ctx, decoders, func(stream int, frame *Frame) error { + ch <- frame + return nil + }); err != nil { + result = err + } + // Close channel at the end of decoding + close(ch) + }() + + // Encoding + wg.Add(1) + go func() { + defer wg.Done() + for frame := range ch { + fmt.Println("TODO: Write frame to output", frame) + } + }() + + // Wait for the process to finish + wg.Wait() + + // Return any errors + return result +} + +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS - DECODE + +type decoderMap map[int]*Decoder + +func (d decoderMap) Close() error { + var result error + for _, decoder := range d { + if err := decoder.Close(); err != nil { + result = errors.Join(result, err) + } + } + return result +} + +// Map streams to decoders, and return the decoders +func (r *Reader) mapStreams(fn DecoderMapFunc) (decoderMap, error) { + decoders := make(decoderMap, r.input.NumStreams()) // Standard decoder map function copies all streams - if mapfn == nil { - mapfn = func(_ int, par *Par) (*Par, error) { + if fn == nil { + fn = func(_ int, par *Par) (*Par, error) { return par, nil } } @@ -247,7 +344,7 @@ func (r *Reader) Decode(ctx context.Context, mapfn DecoderMapFunc, decodefn Deco stream_index := stream.Index() // Get decoder parameters and map to a decoder - par, err := mapfn(stream.Id(), &Par{ + par, err := fn(stream_index, &Par{ AVCodecParameters: *stream.CodecPar(), }) if err != nil { @@ -268,25 +365,15 @@ func (r *Reader) Decode(ctx context.Context, mapfn DecoderMapFunc, decodefn Deco result = errors.Join(result, ErrBadParameter.With("no streams to decode")) } - // Now we have a map of decoders, we can start decoding - if result == nil { - result = r.decode(ctx, decoders, decodefn) - } - - // Release resources - for _, decoder := range decoders { - if err := decoder.Close(); err != nil { - result = errors.Join(result, err) - } + // If there are errors, then free the decoders + if result != nil { + result = errors.Join(result, decoders.Close()) } // Return any errors - return result + return decoders, result } -//////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - DECODE - func (r *Reader) decode(ctx context.Context, decoders map[int]*Decoder, fn DecoderFrameFn) error { // Allocate a packet packet := ff.AVCodec_packet_alloc() diff --git a/pkg/ffmpeg/reader_test.go b/pkg/ffmpeg/reader_test.go index a014a1f..f882ad1 100644 --- a/pkg/ffmpeg/reader_test.go +++ b/pkg/ffmpeg/reader_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + // Packages media "github.com/mutablelogic/go-media" ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" assert "github.com/stretchr/testify/assert" diff --git a/pkg/ffmpeg/resampler.go b/pkg/ffmpeg/resampler.go index ab0308f..8a2f71a 100644 --- a/pkg/ffmpeg/resampler.go +++ b/pkg/ffmpeg/resampler.go @@ -98,10 +98,11 @@ func (r *resampler) Frame(src *Frame) (*Frame, error) { delay := ff.SWResample_get_delay(r.ctx, int64(src.SampleRate())) + int64(src.NumSamples()) num_samples = int(ff.AVUtil_rescale_rnd(delay, int64(r.dest.SampleRate()), int64(src.SampleRate()), ff.AV_ROUND_UP)) } + + // Check number of samples if num_samples < 0 { return nil, errors.New("av_rescale_rnd error") - } - if num_samples == 0 { + } else if num_samples == 0 { return nil, nil } @@ -125,6 +126,8 @@ func (r *resampler) Frame(src *Frame) (*Frame, error) { // Perform resampling if err := ff.SWResample_convert_frame(r.ctx, (*ff.AVFrame)(src), (*ff.AVFrame)(r.dest)); err != nil { return nil, fmt.Errorf("SWResample_convert_frame: %w", err) + } else if r.dest.NumSamples() == 0 { + return nil, nil } // Return the destination frame or nil diff --git a/pkg/ffmpeg/resampler_test.go b/pkg/ffmpeg/resampler_test.go index efbb648..5e4483e 100644 --- a/pkg/ffmpeg/resampler_test.go +++ b/pkg/ffmpeg/resampler_test.go @@ -3,6 +3,7 @@ package ffmpeg_test import ( "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" generator "github.com/mutablelogic/go-media/pkg/generator" assert "github.com/stretchr/testify/assert" @@ -48,7 +49,7 @@ func Test_resampler_001(t *testing.T) { if !assert.NoError(err) { t.FailNow() } - t.Log("FLUSH =>", dest) + t.Log(" =>", dest) if dest == nil { break } diff --git a/pkg/ffmpeg/rescaler_test.go b/pkg/ffmpeg/rescaler_test.go index 85537e2..b85af24 100644 --- a/pkg/ffmpeg/rescaler_test.go +++ b/pkg/ffmpeg/rescaler_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" generator "github.com/mutablelogic/go-media/pkg/generator" assert "github.com/stretchr/testify/assert" diff --git a/pkg/ffmpeg/sampleformat.go b/pkg/ffmpeg/sampleformat.go new file mode 100644 index 0000000..35d1c97 --- /dev/null +++ b/pkg/ffmpeg/sampleformat.go @@ -0,0 +1,63 @@ +package ffmpeg + +import ( + "encoding/json" + + // Packages + ff "github.com/mutablelogic/go-media/sys/ffmpeg61" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type SampleFormat ff.AVSampleFormat + +type jsonSampleFormat struct { + Name string `json:"name"` + IsPlanar bool `json:"is_planar"` + BytesPerSample int `json:"bytes_per_sample"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func newSampleFormat(samplefmt ff.AVSampleFormat) *SampleFormat { + if samplefmt == ff.AV_SAMPLE_FMT_NONE { + return nil + } else if name := ff.AVUtil_get_sample_fmt_name(samplefmt); name == "" { + return nil + } else { + return (*SampleFormat)(&samplefmt) + } +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (samplefmt *SampleFormat) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonSampleFormat{ + Name: samplefmt.Name(), + IsPlanar: samplefmt.IsPlanar(), + BytesPerSample: samplefmt.BytesPerSample(), + }) +} + +func (samplefmt *SampleFormat) String() string { + data, _ := json.MarshalIndent(samplefmt, "", " ") + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PROPERTIES + +func (samplefmt *SampleFormat) Name() string { + return ff.AVUtil_get_sample_fmt_name(ff.AVSampleFormat(*samplefmt)) +} + +func (samplefmt *SampleFormat) IsPlanar() bool { + return ff.AVUtil_sample_fmt_is_planar(ff.AVSampleFormat(*samplefmt)) +} + +func (samplefmt *SampleFormat) BytesPerSample() int { + return ff.AVUtil_get_bytes_per_sample(ff.AVSampleFormat(*samplefmt)) +} diff --git a/pkg/ffmpeg/sampleformat_test.go b/pkg/ffmpeg/sampleformat_test.go new file mode 100644 index 0000000..40d3e1b --- /dev/null +++ b/pkg/ffmpeg/sampleformat_test.go @@ -0,0 +1,22 @@ +package ffmpeg_test + +import ( + "testing" + + // Packages + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + assert "github.com/stretchr/testify/assert" +) + +func Test_sampleformat_001(t *testing.T) { + assert := assert.New(t) + + manager, err := ffmpeg.NewManager() + if !assert.NoError(err) { + t.FailNow() + } + + for _, format := range manager.SampleFormats() { + t.Logf("%v", format) + } +} diff --git a/pkg/ffmpeg/writer.go b/pkg/ffmpeg/writer.go index 27416c1..60c8fa4 100644 --- a/pkg/ffmpeg/writer.go +++ b/pkg/ffmpeg/writer.go @@ -30,6 +30,14 @@ type writer_callback struct { w io.Writer } +// EncoderFrameFn 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 EncoderFrameFn func(int) (*Frame, error) + +// EncoderPacketFn is a function which is called for each packet encoded, with +// the stream timebase. +type EncoderPacketFn func(*Packet) error + ////////////////////////////////////////////////////////////////////////////// // GLOBALS @@ -150,6 +158,15 @@ func (writer *Writer) open(options *opts) (*Writer, error) { } } + // Add artwork + for _, entry := range options.metadata { + // Ignore artwork fields + if entry.Key() != MetaArtwork || len(entry.Bytes()) == 0 { + continue + } + fmt.Println("TODO: Add artwork") + } + // Set metadata, write the header // Metadata ownership is transferred to the output context writer.output.SetMetadata(metadata) diff --git a/pkg/ffmpeg/writer_test.go b/pkg/ffmpeg/writer_test.go index fbac56f..671598a 100644 --- a/pkg/ffmpeg/writer_test.go +++ b/pkg/ffmpeg/writer_test.go @@ -5,6 +5,7 @@ import ( "os" "testing" + // Packages ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" generator "github.com/mutablelogic/go-media/pkg/generator" assert "github.com/stretchr/testify/assert" @@ -38,8 +39,8 @@ func Test_writer_001(t *testing.T) { } defer audio.Close() - // Write 15 mins of frames - duration := float64(15 * 60) + // Write 1 min of frames + duration := float64(60) assert.NoError(writer.Encode(func(stream int) (*ffmpeg.Frame, error) { frame := audio.Frame() if frame.Ts() >= duration { diff --git a/pkg/sdl/sdl.go b/pkg/sdl/sdl.go new file mode 100644 index 0000000..249c070 --- /dev/null +++ b/pkg/sdl/sdl.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "errors" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "time" + "unsafe" + + // Packages + media "github.com/mutablelogic/go-media" + ffmpeg "github.com/mutablelogic/go-media/pkg/ffmpeg" + sdl "github.com/veandco/go-sdl2/sdl" +) + +type Context struct { +} + +type Window struct { + *sdl.Window + *sdl.Renderer + *sdl.Texture +} + +type Surface sdl.Surface + +// Create a new SDL object which can output audio and video +func NewSDL() (*Context, error) { + if err := sdl.Init(sdl.INIT_VIDEO); err != nil { + return nil, err + } + return &Context{}, nil +} + +func (s *Context) Close() error { + sdl.Quit() + return nil +} + +func (s *Context) NewWindow(title string, width, height int32) (*Window, error) { + window, err := sdl.CreateWindow( + title, + sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, + width, height, + sdl.WINDOW_SHOWN|sdl.WINDOW_BORDERLESS) + if err != nil { + return nil, err + } + renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED) + if err != nil { + window.Destroy() + return nil, err + } + texture, err := renderer.CreateTexture(sdl.PIXELFORMAT_IYUV, sdl.TEXTUREACCESS_STREAMING, width, height) + if err != nil { + renderer.Destroy() + window.Destroy() + return nil, err + } + return &Window{window, renderer, texture}, nil +} + +func (w *Window) Close() error { + var result error + if err := (*sdl.Texture)(w.Texture).Destroy(); err != nil { + result = errors.Join(result, err) + } + if err := (*sdl.Renderer)(w.Renderer).Destroy(); err != nil { + result = errors.Join(result, err) + } + if err := (*sdl.Window)(w.Window).Destroy(); err != nil { + result = errors.Join(result, err) + } + w.Texture = nil + w.Renderer = nil + w.Window = nil + + // Return any errors + return result +} + +func (w *Window) Flush() error { + if err := w.Renderer.Copy(w.Texture, nil, nil); err != nil { + return err + } + w.Renderer.Present() + return nil +} + +func (w *Window) RenderFrame(frame *ffmpeg.Frame) error { + return w.UpdateYUV( + nil, + frame.Bytes(0), + frame.Stride(0), + frame.Bytes(1), + frame.Stride(1), + frame.Bytes(2), + frame.Stride(2), + ) +} + +func (s *Context) RunLoop(w *Window, evt uint32) { + runtime.LockOSThread() + running := true + + pts := ffmpeg.TS_UNDEFINED + for running { + for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { + switch event := event.(type) { + case *sdl.QuitEvent: + running = false + case *sdl.UserEvent: + if event.Type != evt { + break + } + + // Get the video frame - if nil, then end of stream + frame := (*ffmpeg.Frame)(event.Data1) + if frame == nil { + running = false + break + } + + // Pause to present the frame at the correct PTS + if pts != ffmpeg.TS_UNDEFINED && pts < frame.Ts() { + pause := frame.Ts() - pts + if pause > 0 { + sdl.Delay(uint32(pause * 1000)) + } + } + + // Set current timestamp + pts = frame.Ts() + + // Render the frame, release the frame resources + if err := w.RenderFrame(frame); err != nil { + log.Print(err) + } else if err := w.Flush(); err != nil { + log.Print(err) + } else if err := frame.Close(); err != nil { + log.Print(err) + } + + } + } + } +} + +func main() { + ctx, err := NewSDL() + if err != nil { + log.Fatal(err) + } + defer ctx.Close() + + // Register an event for a new frame + evt := sdl.RegisterEvents(1) + + // Open video + input, err := ffmpeg.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } + + // Decode frames in a goroutine + var result error + var wg sync.WaitGroup + var w, h int32 + + // Decoder map function + mapfn := func(stream int, par *ffmpeg.Par) (*ffmpeg.Par, error) { + if stream == input.BestStream(media.VIDEO) { + w = int32(par.Width()) + h = int32(par.Height()) + return par, nil + } + return nil, nil + } + + wg.Add(1) + go func() { + defer wg.Done() + err := input.Decode(context.Background(), mapfn, func(stream int, frame *ffmpeg.Frame) error { + copy, err := frame.Copy() + if err != nil { + copy.Close() + return err + } + sdl.PushEvent(&sdl.UserEvent{ + Type: evt, + Data1: unsafe.Pointer(copy), + }) + return nil + }) + if err != nil { + result = errors.Join(result, err) + } + // Quit event + sdl.PushEvent(&sdl.QuitEvent{ + Type: sdl.QUIT, + }) + }() + + // HACK + time.Sleep(100 * time.Millisecond) + if w == 0 || h == 0 { + log.Fatal("No video stream found") + } + + title := filepath.Base(os.Args[1]) + meta := input.Metadata("title") + if len(meta) > 0 { + title = meta[0].Value() + } + + // Create a new window + window, err := ctx.NewWindow(title, w, h) + if err != nil { + log.Fatal(err) + } + defer window.Close() + + // Run the SDL loop until quit + ctx.RunLoop(window, evt) + + // Wait until all goroutines have finished + wg.Wait() + + // Return any errors + if result != nil { + log.Fatal(result) + } +} diff --git a/sys/ffmpeg61/avcodec_packet_test.go b/sys/ffmpeg61/avcodec_packet_test.go index 920dc11..c7d2d79 100644 --- a/sys/ffmpeg61/avcodec_packet_test.go +++ b/sys/ffmpeg61/avcodec_packet_test.go @@ -3,9 +3,10 @@ package ffmpeg_test import ( "testing" + "github.com/stretchr/testify/assert" + // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" - "github.com/stretchr/testify/assert" ) func Test_avcodec_packet_000(t *testing.T) { diff --git a/sys/ffmpeg61/avdevice.go b/sys/ffmpeg61/avdevice.go index a00eead..daf8e20 100644 --- a/sys/ffmpeg61/avdevice.go +++ b/sys/ffmpeg61/avdevice.go @@ -1,5 +1,10 @@ package ffmpeg +import ( + "encoding/json" + "unsafe" +) + //////////////////////////////////////////////////////////////////////////////// // CGO @@ -8,10 +13,6 @@ package ffmpeg #include */ import "C" -import ( - "encoding/json" - "unsafe" -) //////////////////////////////////////////////////////////////////////////////// // TYPES diff --git a/sys/ffmpeg61/avdevice_input.go b/sys/ffmpeg61/avdevice_input.go index c29545c..3b1b06c 100644 --- a/sys/ffmpeg61/avdevice_input.go +++ b/sys/ffmpeg61/avdevice_input.go @@ -1,5 +1,10 @@ package ffmpeg +import ( + "fmt" + "unsafe" +) + //////////////////////////////////////////////////////////////////////////////// // CGO @@ -8,10 +13,6 @@ package ffmpeg #include */ import "C" -import ( - "fmt" - "unsafe" -) //////////////////////////////////////////////////////////////////////////////// // BINDINGS diff --git a/sys/ffmpeg61/avformat_dump.go b/sys/ffmpeg61/avformat_dump.go index 9cf6d15..50c89b9 100644 --- a/sys/ffmpeg61/avformat_dump.go +++ b/sys/ffmpeg61/avformat_dump.go @@ -1,5 +1,7 @@ package ffmpeg +import "unsafe" + //////////////////////////////////////////////////////////////////////////////// // CGO @@ -8,7 +10,6 @@ package ffmpeg #include */ import "C" -import "unsafe" //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS diff --git a/sys/ffmpeg61/avformat_input_test.go b/sys/ffmpeg61/avformat_input_test.go index dcc5c39..66f4760 100644 --- a/sys/ffmpeg61/avformat_input_test.go +++ b/sys/ffmpeg61/avformat_input_test.go @@ -4,10 +4,10 @@ import ( "testing" // Packages + "github.com/stretchr/testify/assert" // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" - "github.com/stretchr/testify/assert" ) func Test_avformat_input_001(t *testing.T) { diff --git a/sys/ffmpeg61/avformat_output_test.go b/sys/ffmpeg61/avformat_output_test.go index 55e4bdd..21ac0ef 100644 --- a/sys/ffmpeg61/avformat_output_test.go +++ b/sys/ffmpeg61/avformat_output_test.go @@ -3,8 +3,6 @@ package ffmpeg_test import ( "testing" - // Packages - // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" ) diff --git a/sys/ffmpeg61/avutil.go b/sys/ffmpeg61/avutil.go index d487738..c25d716 100644 --- a/sys/ffmpeg61/avutil.go +++ b/sys/ffmpeg61/avutil.go @@ -2,6 +2,7 @@ package ffmpeg import ( "encoding/json" + "fmt" ) //////////////////////////////////////////////////////////////////////////////// @@ -55,6 +56,7 @@ type ( AVBufferRef C.struct_AVBufferRef AVChannel C.enum_AVChannel AVChannelLayout C.AVChannelLayout + AVChannelOrder C.enum_AVChannelOrder AVClass C.AVClass AVDictionary struct{ ctx *C.struct_AVDictionary } // Wrapper AVDictionaryEntry C.struct_AVDictionaryEntry @@ -144,6 +146,13 @@ var ( AV_CHANNEL_LAYOUT_AMBISONIC_FIRST_ORDER = AVChannelLayout(C._AV_CHANNEL_LAYOUT_AMBISONIC_FIRST_ORDER) ) +const ( + AV_CHANNEL_ORDER_UNSPEC AVChannelOrder = C.AV_CHANNEL_ORDER_UNSPEC + AV_CHANNEL_ORDER_NATIVE AVChannelOrder = C.AV_CHANNEL_ORDER_NATIVE + AV_CHANNEL_ORDER_CUSTOM AVChannelOrder = C.AV_CHANNEL_ORDER_CUSTOM + AV_CHANNEL_ORDER_AMBISONIC AVChannelOrder = C.AV_CHANNEL_ORDER_AMBISONIC +) + const ( AV_NOPTS_VALUE = C.AV_NOPTS_VALUE ///< Undefined timestamp value ) @@ -158,7 +167,11 @@ const ( ) const ( - AV_TIME_BASE = C.AV_TIME_BASE ///< Internal time base + AV_TIME_BASE = C.AV_TIME_BASE // Internal time base +) + +const ( + AV_CHAN_NONE AVChannel = C.AV_CHAN_NONE // Invalid channel ) //////////////////////////////////////////////////////////////////////////////// @@ -202,3 +215,17 @@ func (ctx *AVDictionary) String() string { return string(str) } } + +func (v AVChannelOrder) String() string { + switch v { + case AV_CHANNEL_ORDER_UNSPEC: + return "AV_CHANNEL_ORDER_UNSPEC" + case AV_CHANNEL_ORDER_NATIVE: + return "AV_CHANNEL_ORDER_NATIVE" + case AV_CHANNEL_ORDER_CUSTOM: + return "AV_CHANNEL_ORDER_CUSTOM" + case AV_CHANNEL_ORDER_AMBISONIC: + return "AV_CHANNEL_ORDER_AMBISONIC" + } + return fmt.Sprintf("AVChannelOrder(%d)", int(v)) +} diff --git a/sys/ffmpeg61/avutil_channel_layout.go b/sys/ffmpeg61/avutil_channel_layout.go index 90ff709..5ed387b 100644 --- a/sys/ffmpeg61/avutil_channel_layout.go +++ b/sys/ffmpeg61/avutil_channel_layout.go @@ -72,7 +72,7 @@ func AVUtil_channel_layout_standard(iterator *uintptr) *AVChannelLayout { return (*AVChannelLayout)(C.av_channel_layout_standard((*unsafe.Pointer)(unsafe.Pointer(iterator)))) } -// Iterate over all standard channel layouts. +// Get a human-readable string describing the channel layout properties. func AVUtil_channel_layout_describe(channel_layout *AVChannelLayout) (string, error) { if n := C.av_channel_layout_describe((*C.struct_AVChannelLayout)(channel_layout), &cBuf[0], cBufSize); n < 0 { return "", AVError(n) @@ -137,3 +137,7 @@ func AVUtil_channel_layout_compare(a *AVChannelLayout, b *AVChannelLayout) bool func (ctx AVChannelLayout) NumChannels() int { return int(ctx.nb_channels) } + +func (ctx AVChannelLayout) Order() AVChannelOrder { + return AVChannelOrder(ctx.order) +} diff --git a/sys/ffmpeg61/avutil_channel_layout_test.go b/sys/ffmpeg61/avutil_channel_layout_test.go index 16ed928..a2884f2 100644 --- a/sys/ffmpeg61/avutil_channel_layout_test.go +++ b/sys/ffmpeg61/avutil_channel_layout_test.go @@ -3,9 +3,10 @@ package ffmpeg_test import ( "testing" + "github.com/stretchr/testify/assert" + // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" - "github.com/stretchr/testify/assert" ) func Test_avutil_channel_layout_001(t *testing.T) { diff --git a/sys/ffmpeg61/avutil_dict_test.go b/sys/ffmpeg61/avutil_dict_test.go index 89b389f..a2e8dcb 100644 --- a/sys/ffmpeg61/avutil_dict_test.go +++ b/sys/ffmpeg61/avutil_dict_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" // Namespace imports - . "github.com/mutablelogic/go-media/sys/ffmpeg61" ) diff --git a/sys/ffmpeg61/avutil_frame.go b/sys/ffmpeg61/avutil_frame.go index e3502fb..88ff4e6 100644 --- a/sys/ffmpeg61/avutil_frame.go +++ b/sys/ffmpeg61/avutil_frame.go @@ -40,11 +40,10 @@ type jsonAVVideoFrame struct { type jsonAVFrame struct { *jsonAVAudioFrame *jsonAVVideoFrame - NumPlanes int `json:"num_planes,omitempty"` - PlaneBytes []int `json:"plane_bytes,omitempty"` - Pts AVTimestamp `json:"pts,omitempty"` - BestEffortTs AVTimestamp `json:"best_effort_timestamp,omitempty"` - TimeBase AVRational `json:"time_base,omitempty"` + NumPlanes int `json:"num_planes,omitempty"` + PlaneBytes []int `json:"plane_bytes,omitempty"` + Pts AVTimestamp `json:"pts,omitempty"` + TimeBase AVRational `json:"time_base,omitempty"` } func (ctx *AVFrame) MarshalJSON() ([]byte, error) { @@ -58,11 +57,10 @@ func (ctx *AVFrame) MarshalJSON() ([]byte, error) { ChannelLayout: AVChannelLayout(ctx.ch_layout), BytesPerSample: AVUtil_get_bytes_per_sample(AVSampleFormat(ctx.format)), }, - Pts: AVTimestamp(ctx.pts), - BestEffortTs: AVTimestamp(ctx.best_effort_timestamp), - TimeBase: AVRational(ctx.time_base), - NumPlanes: AVUtil_frame_get_num_planes(ctx), - PlaneBytes: ctx.planesizes(), + Pts: AVTimestamp(ctx.pts), + TimeBase: AVRational(ctx.time_base), + NumPlanes: AVUtil_frame_get_num_planes(ctx), + PlaneBytes: ctx.planesizes(), }) } else if ctx.width != 0 && ctx.height != 0 && ctx.PixFmt() != AV_PIX_FMT_NONE { // Video @@ -152,6 +150,14 @@ func AVUtil_frame_get_num_planes(frame *AVFrame) int { return 0 } +// Copy frame data +func AVUtil_frame_copy(dst, src *AVFrame) error { + if ret := AVError(C.av_frame_copy((*C.struct_AVFrame)(dst), (*C.struct_AVFrame)(src))); ret < 0 { + return ret + } + return nil +} + // Copy only "metadata" fields from src to dst, those fields that do not affect the data layout in the buffers. // E.g. pts, sample rate (for audio) or sample aspect ratio (for video), but not width/height or channel layout. // Side data is also copied. diff --git a/sys/ffmpeg61/avutil_pixfmt_test.go b/sys/ffmpeg61/avutil_pixfmt_test.go index fa9caf3..1e3d92f 100644 --- a/sys/ffmpeg61/avutil_pixfmt_test.go +++ b/sys/ffmpeg61/avutil_pixfmt_test.go @@ -3,8 +3,6 @@ package ffmpeg_test import ( "testing" - // Packages - // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" ) diff --git a/sys/ffmpeg61/swscale_core_test.go b/sys/ffmpeg61/swscale_core_test.go index 4ee7bcb..c135491 100644 --- a/sys/ffmpeg61/swscale_core_test.go +++ b/sys/ffmpeg61/swscale_core_test.go @@ -3,9 +3,10 @@ package ffmpeg_test import ( "testing" + "github.com/stretchr/testify/assert" + // Namespace imports . "github.com/mutablelogic/go-media/sys/ffmpeg61" - "github.com/stretchr/testify/assert" ) func Test_swscale_core_000(t *testing.T) { diff --git a/type.go b/type.go index 1243d55..8a3aba1 100644 --- a/type.go +++ b/type.go @@ -1,39 +1,56 @@ package media -import ( - // Packages - ff "github.com/mutablelogic/go-media/sys/ffmpeg61" -) - /////////////////////////////////////////////////////////////////////////////// // TYPES -type Type ff.AVMediaType +// Type of codec, device, format or stream +type Type int /////////////////////////////////////////////////////////////////////////////// // GLOBALS const ( - UNKNOWN Type = Type(ff.AVMEDIA_TYPE_UNKNOWN) - VIDEO Type = Type(ff.AVMEDIA_TYPE_VIDEO) - AUDIO Type = Type(ff.AVMEDIA_TYPE_AUDIO) - DATA Type = Type(ff.AVMEDIA_TYPE_DATA) - SUBTITLE Type = Type(ff.AVMEDIA_TYPE_SUBTITLE) + 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 ) /////////////////////////////////////////////////////////////////////////////// // STINGIFY +// 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 { + if t&f == f { + str += "|" + f.FlagString() + } + } + return str[1:] +} + +// Return a flag as a string +func (t Type) FlagString() string { switch t { + case NONE: + return "NONE" case VIDEO: return "VIDEO" case AUDIO: return "AUDIO" - case DATA: - return "DATA" case SUBTITLE: return "SUBTITLE" + case DATA: + return "DATA" default: return "UNKNOWN" } @@ -42,7 +59,7 @@ func (t Type) String() string { /////////////////////////////////////////////////////////////////////////////// // METHODS +// Returns true if the type matches a set of flags func (t Type) Is(u Type) bool { - // TODO: Change later to flags - return t == u + return t&u == u }