├── test_data ├── test_audio.wav └── test_video.mp4 ├── go.mod ├── go.sum ├── audio_info_test.go ├── video_info_test.go ├── audio_writer_test.go ├── examples ├── reverse_video │ └── main.go ├── gif_to_video │ └── main.go └── blur_video │ └── main.go ├── video_reader_test.go ├── audio_reader_test.go ├── LICENSE ├── audio_info.go ├── README.md ├── video_writer_test.go ├── audio_writer.go ├── video_info.go ├── video_reader.go ├── audio_reader.go ├── video_writer.go └── child_stream.go /test_data/test_audio.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixpickle/ffmpego/HEAD/test_data/test_audio.wav -------------------------------------------------------------------------------- /test_data/test_video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixpickle/ffmpego/HEAD/test_data/test_video.mp4 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/unixpickle/ffmpego 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/pkg/errors v0.9.1 7 | github.com/unixpickle/essentials v1.1.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 2 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 3 | github.com/unixpickle/essentials v1.1.0 h1:kJ/mU3MfmmSfuU8zyplwkup60lKV9+ucqZC+hR1GgVU= 4 | github.com/unixpickle/essentials v1.1.0/go.mod h1:dQ1idvqrgrDgub3mfckQm7osVPzT3u9rB6NK/LEhmtQ= 5 | -------------------------------------------------------------------------------- /audio_info_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestAudioInfo(t *testing.T) { 9 | info, err := GetAudioInfo(filepath.Join("test_data", "test_audio.wav")) 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | if info.Frequency != 8000 { 14 | t.Errorf("expected frequency 8000 but got %d", info.Frequency) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /video_info_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestVideoInfo(t *testing.T) { 9 | info, err := GetVideoInfo(filepath.Join("test_data", "test_video.mp4")) 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | if info.Width != 64 { 14 | t.Errorf("expected width 64 but got %d", info.Width) 15 | } 16 | if info.Height != 32 { 17 | t.Errorf("expected height 32 but got %d", info.Height) 18 | } 19 | if info.FPS != 12 { 20 | t.Errorf("expected FPS 12 but got %f", info.FPS) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /audio_writer_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "io/ioutil" 5 | "math" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestAudioWriter(t *testing.T) { 12 | dir, err := ioutil.TempDir("", "test-audio-writer") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | defer os.RemoveAll(dir) 17 | outPath := filepath.Join(dir, "out.wav") 18 | aw, err := NewAudioWriter(outPath, 44100) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | for i := 0; i < 1; i++ { 23 | samples := make([]float64, 8000) 24 | for t := range samples { 25 | arg := math.Pi * 2 * 400 * float64(t) / float64(len(samples)) 26 | samples[t] = math.Sin(arg) 27 | } 28 | if err := aw.WriteSamples(samples); err != nil { 29 | aw.Close() 30 | t.Fatal(err) 31 | } 32 | } 33 | if err := aw.Close(); err != nil { 34 | t.Fatal(err) 35 | } 36 | if _, err := os.Stat(outPath); err != nil { 37 | t.Fatal("stat output file should work but got:", err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/reverse_video/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "io" 7 | "log" 8 | "os" 9 | 10 | "github.com/unixpickle/essentials" 11 | "github.com/unixpickle/ffmpego" 12 | ) 13 | 14 | func main() { 15 | if len(os.Args) != 3 { 16 | fmt.Fprintln(os.Stderr, "Usage: reverse_video ") 17 | os.Exit(1) 18 | } 19 | inputFile := os.Args[1] 20 | outputFile := os.Args[2] 21 | 22 | reader, err := ffmpego.NewVideoReader(inputFile) 23 | essentials.Must(err) 24 | defer reader.Close() 25 | info := reader.VideoInfo() 26 | 27 | log.Println("Reading video...") 28 | var frames []image.Image 29 | for { 30 | frame, err := reader.ReadFrame() 31 | if err == io.EOF { 32 | break 33 | } 34 | essentials.Must(err) 35 | frames = append(frames, frame) 36 | } 37 | 38 | log.Println("Encoding video...") 39 | writer, err := ffmpego.NewVideoWriter(outputFile, info.Width, info.Height, info.FPS) 40 | essentials.Must(err) 41 | defer writer.Close() 42 | for i := len(frames) - 1; i >= 0; i-- { 43 | essentials.Must(writer.WriteFrame(frames[i])) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /video_reader_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "io" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestVideoReader(t *testing.T) { 10 | reader, err := NewVideoReader(filepath.Join("test_data", "test_video.mp4")) 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | testVideoReader(t, reader, 24) 15 | } 16 | 17 | func TestVideoReaderResampled(t *testing.T) { 18 | reader, err := NewVideoReaderResampled(filepath.Join("test_data", "test_video.mp4"), 20) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | testVideoReader(t, reader, 40) 23 | } 24 | 25 | func testVideoReader(t *testing.T, reader *VideoReader, expectedFrames int) { 26 | defer func() { 27 | if err := reader.Close(); err != nil { 28 | t.Error(err) 29 | } 30 | }() 31 | numFrames := 0 32 | for { 33 | frame, err := reader.ReadFrame() 34 | if err == io.EOF { 35 | break 36 | } else if err != nil { 37 | t.Fatal(err) 38 | } 39 | numFrames++ 40 | if frame.Bounds().Dx() != 64 || frame.Bounds().Dy() != 32 { 41 | t.Error("bad video bounds:", frame.Bounds()) 42 | } 43 | } 44 | if numFrames != expectedFrames { 45 | t.Errorf("incorrect number of frames: %d", numFrames) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /audio_reader_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "io" 5 | "math/rand" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestAudioReader(t *testing.T) { 11 | reader, err := NewAudioReader(filepath.Join("test_data", "test_audio.wav")) 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | testAudioReader(t, reader, 8000) 16 | } 17 | 18 | func TestAudioReaderResampled(t *testing.T) { 19 | reader, err := NewAudioReaderResampled(filepath.Join("test_data", "test_audio.wav"), 16000) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | testAudioReader(t, reader, 16000) 24 | } 25 | 26 | func testAudioReader(t *testing.T, reader *AudioReader, expectedSamples int) { 27 | defer func() { 28 | if err := reader.Close(); err != nil { 29 | t.Error(err) 30 | } 31 | }() 32 | numSamples := 0 33 | for { 34 | chunk := make([]float64, rand.Intn(100)+100) 35 | n, err := reader.ReadSamples(chunk) 36 | if n != len(chunk) { 37 | if err == nil { 38 | t.Error("expected error if fewer bytes are read") 39 | } 40 | } 41 | numSamples += n 42 | if err == io.EOF { 43 | break 44 | } else if err != nil { 45 | t.Fatal(err) 46 | } 47 | } 48 | if numSamples != expectedSamples { 49 | t.Errorf("incorrect number of samples: %d", numSamples) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2021, Alexander Nichol. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /audio_info.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // AudioInfo stores information about an audio file. 13 | type AudioInfo struct { 14 | // Frequency stores the frequency in Hz. 15 | Frequency int 16 | } 17 | 18 | // GetAudioInfo gets information about a audio file. 19 | func GetAudioInfo(path string) (info *AudioInfo, err error) { 20 | defer func() { 21 | if err != nil { 22 | err = errors.Wrap(err, "get audio info") 23 | } 24 | }() 25 | 26 | // Make sure file exists so we can give a clean error 27 | // message in this case, instead of depending on ffmpeg. 28 | if _, err := os.Stat(path); err != nil { 29 | return nil, err 30 | } 31 | 32 | lines, err := infoOutputLines(path) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var foundFreq bool 38 | result := &AudioInfo{} 39 | 40 | freqExp := regexp.MustCompilePOSIX(" ([0-9\\.]*) Hz,") 41 | for _, line := range lines { 42 | if !strings.Contains(line, "Audio:") { 43 | continue 44 | } 45 | if match := freqExp.FindStringSubmatch(line); match != nil { 46 | foundFreq = true 47 | freq, err := strconv.Atoi(match[1]) 48 | if err != nil { 49 | return nil, errors.Wrap(err, "parse frequency") 50 | } 51 | result.Frequency = freq 52 | } 53 | } 54 | 55 | if !foundFreq { 56 | return nil, errors.New("could not find frequency in output") 57 | } 58 | return result, nil 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ffmpego 2 | 3 | **ffmpego** is a Go wrapper around the `ffmpeg` command for reading and writing videos. It can be used to programmatically manipulate media with a simple, friendly interface. 4 | 5 | # Usage 6 | 7 | ## Writing a video 8 | 9 | To encode a video, create a `VideoWriter` and write `image.Image`s to it. Here's the simplest possible example of encoding a video: 10 | 11 | ```go 12 | fps := 24.0 13 | width := 50 14 | height := 50 15 | 16 | vw, _ := ffmpego.NewVideoWriter("output.mp4", width, height, fps) 17 | 18 | for i := 0; i < 24; i++ { 19 | // Create your image. 20 | frame := image.NewGray(image.Rect(0, 0, width, height)) 21 | 22 | vw.WriteFrame(frame) 23 | } 24 | 25 | vw.Close() 26 | ``` 27 | 28 | ## Reading a video 29 | 30 | Decoding a video is similarly straightforward. Simply create a `VideoReader` and read `image.Image`s from it: 31 | 32 | ```go 33 | vr, _ := NewVideoReader("input.mp4") 34 | 35 | for { 36 | frame, err := vr.ReadFrame() 37 | if err == io.EOF { 38 | break 39 | } 40 | // Do something with `frame` here... 41 | } 42 | 43 | vr.Close() 44 | ``` 45 | 46 | # Installation 47 | 48 | This project depends on the `ffmpeg` command. If you have `ffmpeg` installed, **ffmpego** should already work out of the box. 49 | 50 | If you do not already have ffmpeg, you can typically install it using your OS's package manager. 51 | 52 | Ubuntu: 53 | 54 | ``` 55 | $ apt install ffmpeg 56 | ``` 57 | 58 | macOS: 59 | 60 | ``` 61 | $ brew install ffmpeg 62 | ``` 63 | 64 | -------------------------------------------------------------------------------- /video_writer_test.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "image" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestVideoWriter(t *testing.T) { 12 | dir, err := ioutil.TempDir("", "test-video-writer") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | defer os.RemoveAll(dir) 17 | outPath := filepath.Join(dir, "out.mp4") 18 | vw, err := NewVideoWriter(outPath, 50, 50, 12) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | for i := 0; i < 24; i++ { 23 | frame := image.NewGray(image.Rect(0, 0, 50, 50)) 24 | for j := 0; j < (len(frame.Pix)*i)/24; j++ { 25 | frame.Pix[j] = 0xff 26 | } 27 | if err := vw.WriteFrame(frame); err != nil { 28 | vw.Close() 29 | t.Fatal(err) 30 | } 31 | } 32 | if err := vw.Close(); err != nil { 33 | t.Fatal(err) 34 | } 35 | if _, err := os.Stat(outPath); err != nil { 36 | t.Fatal("stat output file should work but got:", err) 37 | } 38 | } 39 | 40 | func TestVideoWriterWithAudio(t *testing.T) { 41 | dir, err := ioutil.TempDir("", "test-video-writer-audio") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | defer os.RemoveAll(dir) 46 | 47 | outPath := filepath.Join(dir, "out.mp4") 48 | vw, err := NewVideoWriterWithAudio(outPath, 200, 200, 30, filepath.Join("test_data/test_audio.wav")) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | for i := 0; i < 60; i++ { 53 | frame := image.NewGray(image.Rect(0, 0, 200, 200)) 54 | if err := vw.WriteFrame(frame); err != nil { 55 | vw.Close() 56 | t.Fatal(err) 57 | } 58 | } 59 | if err := vw.Close(); err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | if _, err := os.Stat(outPath); err != nil { 64 | t.Fatal("stat output file should work but got:", err) 65 | } 66 | 67 | audioInfo, err := GetAudioInfo(outPath) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | if audioInfo.Frequency != 8000 { 72 | t.Error("expected frequency 8000 but got", audioInfo.Frequency) 73 | } 74 | 75 | videoInfo, err := GetVideoInfo(outPath) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | if videoInfo.Width != 200 || videoInfo.Height != 200 || videoInfo.FPS != 30 { 80 | t.Errorf("bad video info: %#v", videoInfo) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /audio_writer.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "encoding/binary" 5 | "io" 6 | "os/exec" 7 | "strconv" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // An AudioWriter encodes an audio file using ffmpeg. 13 | type AudioWriter struct { 14 | command *exec.Cmd 15 | writer io.WriteCloser 16 | } 17 | 18 | // NewAudioWriter creates a AudioWriter which is encoding 19 | // mono-channel audio to the given file. 20 | func NewAudioWriter(path string, frequency int) (*AudioWriter, error) { 21 | vw, err := newAudioWriter(path, frequency) 22 | if err != nil { 23 | err = errors.Wrap(err, "write audio") 24 | } 25 | return vw, err 26 | } 27 | 28 | func newAudioWriter(path string, frequency int) (*AudioWriter, error) { 29 | stream, err := CreateChildStream(false) 30 | if err != nil { 31 | return nil, err 32 | } 33 | cmd := exec.Command( 34 | "ffmpeg", 35 | "-y", 36 | // Audio format 37 | "-ar", strconv.Itoa(frequency), "-ac", "1", "-f", "s16le", 38 | // Audio parameters 39 | "-probesize", "32", "-thread_queue_size", "60", "-i", stream.ResourceURL(), 40 | // Output parameters 41 | "-pix_fmt", "yuv420p", path, 42 | ) 43 | cmd.ExtraFiles = stream.ExtraFiles() 44 | if err := cmd.Start(); err != nil { 45 | stream.Cancel() 46 | return nil, err 47 | } 48 | writer, err := stream.Connect() 49 | if err != nil { 50 | cmd.Process.Kill() 51 | return nil, err 52 | } 53 | return &AudioWriter{ 54 | command: cmd, 55 | writer: writer, 56 | }, nil 57 | } 58 | 59 | // WriteSamples writes audio samples to the file. 60 | // 61 | // The samples should be in the range [-1, 1]. 62 | func (v *AudioWriter) WriteSamples(samples []float64) error { 63 | intData := make([]int16, len(samples)) 64 | for i, x := range samples { 65 | intData[i] = int16(x * (1<<15 - 1)) 66 | } 67 | if err := binary.Write(v.writer, binary.LittleEndian, intData); err != nil { 68 | return errors.Wrap(err, "write samples") 69 | } 70 | return nil 71 | } 72 | 73 | // Close closes the audio file and waits for encoding to 74 | // complete. 75 | func (v *AudioWriter) Close() error { 76 | v.writer.Close() 77 | err := v.command.Wait() 78 | if err != nil { 79 | return errors.Wrap(err, "close audio writer") 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /video_info.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // VideoInfo is information about an encoded video. 14 | type VideoInfo struct { 15 | Width int 16 | Height int 17 | FPS float64 18 | } 19 | 20 | // GetVideoInfo gets information about a video file. 21 | func GetVideoInfo(path string) (info *VideoInfo, err error) { 22 | defer func() { 23 | if err != nil { 24 | err = errors.Wrap(err, "get video info") 25 | } 26 | }() 27 | 28 | // Make sure file exists so we can give a clean error 29 | // message in this case, instead of depending on ffmpeg. 30 | if _, err := os.Stat(path); err != nil { 31 | return nil, err 32 | } 33 | 34 | lines, err := infoOutputLines(path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | var foundFPS, foundSize bool 40 | result := &VideoInfo{} 41 | 42 | fpsExp := regexp.MustCompilePOSIX(" ([0-9\\.]*) fps,") 43 | sizeExp := regexp.MustCompilePOSIX(" ([0-9]+)x([0-9]+)(,| )") 44 | for _, line := range lines { 45 | if !strings.Contains(line, "Video:") { 46 | continue 47 | } 48 | if match := fpsExp.FindStringSubmatch(line); match != nil { 49 | foundFPS = true 50 | fps, err := strconv.ParseFloat(match[1], 0) 51 | if err != nil { 52 | return nil, errors.Wrap(err, "parse FPS") 53 | } 54 | result.FPS = fps 55 | } 56 | if match := sizeExp.FindStringSubmatch(line); match != nil { 57 | foundSize = true 58 | var size [2]int 59 | for i, s := range match[1:3] { 60 | n, err := strconv.Atoi(s) 61 | if err != nil { 62 | return nil, errors.Wrap(err, "parse dimensions") 63 | } 64 | size[i] = n 65 | } 66 | result.Width = size[0] 67 | result.Height = size[1] 68 | } 69 | } 70 | 71 | if !foundFPS { 72 | return nil, errors.New("could not find fps in output") 73 | } 74 | if !foundSize { 75 | return nil, errors.New("could not find dimensions in output") 76 | } 77 | return result, nil 78 | } 79 | 80 | func infoOutputLines(path string) ([]string, error) { 81 | cmd := exec.Command("ffmpeg", "-i", path) 82 | out, err := cmd.CombinedOutput() 83 | if err != nil { 84 | // An error exit status is expected, since we didn't do any 85 | // transcoding, we are just using the video info. 86 | err = errors.Cause(err) 87 | if _, ok := err.(*exec.ExitError); !ok { 88 | return nil, err 89 | } 90 | } 91 | return strings.Split(string(out), "\n"), nil 92 | } 93 | -------------------------------------------------------------------------------- /examples/gif_to_video/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "image/gif" 8 | "os" 9 | 10 | "github.com/unixpickle/essentials" 11 | "github.com/unixpickle/ffmpego" 12 | ) 13 | 14 | func main() { 15 | if len(os.Args) != 3 { 16 | fmt.Fprintln(os.Stderr, "Usage: gif_to_video ") 17 | os.Exit(1) 18 | } 19 | inputFile := os.Args[1] 20 | outputFile := os.Args[2] 21 | 22 | r, err := os.Open(inputFile) 23 | essentials.Must(err) 24 | defer r.Close() 25 | 26 | gifImage, err := gif.DecodeAll(r) 27 | essentials.Must(err) 28 | 29 | delay := 0.0 30 | for _, frameDelay := range gifImage.Delay { 31 | delay += float64(frameDelay) / float64(100*len(gifImage.Delay)) 32 | } 33 | fps := 1 / delay 34 | 35 | bounds := BoundsForGIF(gifImage) 36 | writer, err := ffmpego.NewVideoWriter(outputFile, bounds.Dx(), bounds.Dy(), fps) 37 | essentials.Must(err) 38 | defer func() { 39 | essentials.Must(writer.Close()) 40 | }() 41 | 42 | FramesFromGIF(gifImage, func(img image.Image) { 43 | essentials.Must(writer.WriteFrame(img)) 44 | }) 45 | } 46 | 47 | func BoundsForGIF(g *gif.GIF) image.Rectangle { 48 | result := g.Image[0].Bounds() 49 | for _, frame := range g.Image { 50 | result = result.Union(frame.Bounds()) 51 | } 52 | return result 53 | } 54 | 55 | func FramesFromGIF(g *gif.GIF, f func(image.Image)) { 56 | out := image.NewRGBA(BoundsForGIF(g)) 57 | previous := image.NewRGBA(BoundsForGIF(g)) 58 | for i, frame := range g.Image { 59 | disposal := g.Disposal[i] 60 | switch disposal { 61 | case 0: 62 | clearImage(out) 63 | clearImage(previous) 64 | drawImageWithBackground(out, frame, out) 65 | drawImageWithBackground(previous, frame, previous) 66 | case gif.DisposalNone: 67 | drawImageWithBackground(out, frame, out) 68 | case gif.DisposalPrevious: 69 | drawImageWithBackground(out, frame, previous) 70 | case gif.DisposalBackground: 71 | bgColor := g.Config.ColorModel.(color.Palette)[g.BackgroundIndex] 72 | fillImage(out, bgColor) 73 | drawImageWithBackground(out, frame, out) 74 | } 75 | f(out) 76 | } 77 | } 78 | 79 | func drawImageWithBackground(dst *image.RGBA, src, bg image.Image) { 80 | b := src.Bounds() 81 | for y := b.Min.Y; y < b.Max.Y; y++ { 82 | for x := b.Min.X; x < b.Max.X; x++ { 83 | px := src.At(x, y) 84 | _, _, _, a := px.RGBA() 85 | if a == 0 { 86 | px = bg.At(x, y) 87 | } 88 | dst.Set(x, y, px) 89 | } 90 | } 91 | } 92 | 93 | func clearImage(dst *image.RGBA) { 94 | for i := range dst.Pix { 95 | dst.Pix[i] = 0 96 | } 97 | } 98 | 99 | func fillImage(dst *image.RGBA, c color.Color) { 100 | b := dst.Bounds() 101 | for y := b.Min.Y; y < b.Max.Y; y++ { 102 | for x := b.Min.X; x < b.Max.X; x++ { 103 | dst.Set(x, y, c) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /video_reader.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "io" 8 | "os/exec" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // A VideoReader decodes a video file using ffmpeg. 14 | type VideoReader struct { 15 | command *exec.Cmd 16 | reader io.ReadCloser 17 | info *VideoInfo 18 | } 19 | 20 | func NewVideoReader(path string) (*VideoReader, error) { 21 | vr, err := newVideoReader(path, -1) 22 | if err != nil { 23 | err = errors.Wrap(err, "read video") 24 | } 25 | return vr, err 26 | } 27 | 28 | // NewVideoReaderResampled creates a VideoReader that 29 | // automatically changes the input frame rate. 30 | func NewVideoReaderResampled(path string, fps float64) (*VideoReader, error) { 31 | if fps <= 0 { 32 | panic("FPS must be positive") 33 | } 34 | vr, err := newVideoReader(path, fps) 35 | if err != nil { 36 | err = errors.Wrap(err, "read video") 37 | } 38 | return vr, err 39 | } 40 | 41 | func newVideoReader(path string, resampleFPS float64) (*VideoReader, error) { 42 | info, err := GetVideoInfo(path) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if resampleFPS > 0 { 48 | info.FPS = resampleFPS 49 | } 50 | 51 | stream, err := CreateChildStream(true) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | args := []string{ 57 | "-i", path, 58 | "-f", "rawvideo", "-pix_fmt", "rgb24", 59 | } 60 | if resampleFPS > 0 { 61 | args = append(args, "-filter:v", fmt.Sprintf("fps=fps=%f", resampleFPS)) 62 | } 63 | args = append(args, stream.ResourceURL()) 64 | 65 | cmd := exec.Command("ffmpeg", args...) 66 | cmd.ExtraFiles = stream.ExtraFiles() 67 | if err := cmd.Start(); err != nil { 68 | stream.Cancel() 69 | return nil, err 70 | } 71 | reader, err := stream.Connect() 72 | if err != nil { 73 | cmd.Process.Kill() 74 | return nil, err 75 | } 76 | return &VideoReader{ 77 | command: cmd, 78 | reader: reader, 79 | info: info, 80 | }, nil 81 | } 82 | 83 | // VideoInfo gets information about the current video. 84 | func (v *VideoReader) VideoInfo() *VideoInfo { 85 | return v.info 86 | } 87 | 88 | // ReadFrame reads the next frame from the video. 89 | // 90 | // If the video is finished decoding, nil will be returned 91 | // along with io.EOF. 92 | func (v *VideoReader) ReadFrame() (image.Image, error) { 93 | buf := make([]byte, 3*v.info.Width*v.info.Height) 94 | if _, err := io.ReadFull(v.reader, buf); err != nil { 95 | return nil, err 96 | } 97 | img := image.NewRGBA(image.Rect(0, 0, v.info.Width, v.info.Height)) 98 | for y := 0; y < v.info.Height; y++ { 99 | for x := 0; x < v.info.Width; x++ { 100 | rgb := buf[:3] 101 | buf = buf[3:] 102 | img.Set(x, y, &color.RGBA{ 103 | R: rgb[0], 104 | G: rgb[1], 105 | B: rgb[2], 106 | A: 0xff, 107 | }) 108 | } 109 | } 110 | return img, nil 111 | } 112 | 113 | // Close stops the decoding process and closes all 114 | // associated files. 115 | func (v *VideoReader) Close() error { 116 | // When we close the pipe, the subprocess should terminate 117 | // (possibly with an error) because it cannot write. 118 | v.reader.Close() 119 | v.command.Wait() 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /audio_reader.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io" 7 | "os/exec" 8 | "strconv" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // A AudioReader decodes an audio file using ffmpeg. 14 | type AudioReader struct { 15 | command *exec.Cmd 16 | reader io.ReadCloser 17 | info *AudioInfo 18 | } 19 | 20 | func NewAudioReader(path string) (*AudioReader, error) { 21 | vr, err := newAudioReader(path, -1) 22 | if err != nil { 23 | err = errors.Wrap(err, "read audio") 24 | } 25 | return vr, err 26 | } 27 | 28 | // NewAudioReaderResampled creates an AudioReader that 29 | // automatically changes the input frequency. 30 | func NewAudioReaderResampled(path string, frequency int) (*AudioReader, error) { 31 | if frequency <= 0 { 32 | panic("frequency must be positive") 33 | } 34 | vr, err := newAudioReader(path, frequency) 35 | if err != nil { 36 | err = errors.Wrap(err, "read audio") 37 | } 38 | return vr, err 39 | } 40 | 41 | func newAudioReader(path string, forceFrequency int) (*AudioReader, error) { 42 | info, err := GetAudioInfo(path) 43 | if err != nil { 44 | return nil, err 45 | } 46 | if forceFrequency > 0 { 47 | info.Frequency = forceFrequency 48 | } 49 | 50 | stream, err := CreateChildStream(true) 51 | if err != nil { 52 | return nil, err 53 | } 54 | cmd := exec.Command( 55 | "ffmpeg", 56 | "-i", path, 57 | "-f", "s16le", 58 | "-ar", strconv.Itoa(info.Frequency), 59 | "-ac", "1", 60 | stream.ResourceURL(), 61 | ) 62 | cmd.ExtraFiles = stream.ExtraFiles() 63 | if err := cmd.Start(); err != nil { 64 | stream.Cancel() 65 | return nil, err 66 | } 67 | reader, err := stream.Connect() 68 | if err != nil { 69 | cmd.Process.Kill() 70 | return nil, err 71 | } 72 | return &AudioReader{ 73 | command: cmd, 74 | reader: reader, 75 | info: info, 76 | }, nil 77 | } 78 | 79 | // AudioInfo gets information about the current video. 80 | func (a *AudioReader) AudioInfo() *AudioInfo { 81 | return a.info 82 | } 83 | 84 | // ReadSamples reads up to len(samples) from the file. 85 | // 86 | // Returns the number of samples actually read, along with 87 | // an error if one was encountered. 88 | // 89 | // If fewer samples than len(out) are read, an error must 90 | // be returned. 91 | // At the end of decoding, io.EOF is returned. 92 | func (a *AudioReader) ReadSamples(out []float64) (int, error) { 93 | buf := make([]byte, 2*len(out)) 94 | n, err := io.ReadFull(a.reader, buf) 95 | if err != nil { 96 | if err == io.ErrUnexpectedEOF || err == io.EOF { 97 | if n%2 == 0 { 98 | err = io.EOF 99 | } else { 100 | err = io.ErrUnexpectedEOF 101 | n -= 1 102 | } 103 | } 104 | } 105 | if n%2 != 0 { 106 | n -= 1 107 | } 108 | data := make([]int16, n/2) 109 | binary.Read(bytes.NewReader(buf[:n]), binary.LittleEndian, data) 110 | for i, x := range data { 111 | out[i] = float64(x) / float64(1<<15-1) 112 | } 113 | return len(data), err 114 | } 115 | 116 | // Close stops the decoding process and closes all 117 | // associated files. 118 | func (a *AudioReader) Close() error { 119 | // When we close the pipe, the subprocess should terminate 120 | // (possibly with an error) because it cannot write. 121 | a.reader.Close() 122 | a.command.Wait() 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /video_writer.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "io" 7 | "os/exec" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // A VideoWriter encodes a video file using ffmpeg. 13 | type VideoWriter struct { 14 | command *exec.Cmd 15 | writer io.WriteCloser 16 | width int 17 | height int 18 | } 19 | 20 | // NewVideoWriter creates a VideoWriter which is encoding 21 | // to the given file. 22 | func NewVideoWriter(path string, width, height int, fps float64) (*VideoWriter, error) { 23 | vw, err := newVideoWriter(path, width, height, fps) 24 | if err != nil { 25 | err = errors.Wrap(err, "write video") 26 | } 27 | return vw, err 28 | } 29 | 30 | // NewVideoWriterWithAudio creates a VideoWriter which 31 | // copies audio from an existing video or audio file. 32 | func NewVideoWriterWithAudio(path string, width, height int, fps float64, audioFile string) (*VideoWriter, error) { 33 | vw, err := newVideoWriter( 34 | path, width, height, fps, 35 | // Copy audio from input file. 36 | "-i", audioFile, "-c:a", "copy", 37 | // Map video from first input, audio from second. 38 | "-map", "0:v:0", "-map", "1:a:0?", 39 | ) 40 | if err != nil { 41 | err = errors.Wrap(err, "write video with audio") 42 | } 43 | return vw, err 44 | } 45 | 46 | func newVideoWriter(path string, width, height int, fps float64, extraFlags ...string) (*VideoWriter, error) { 47 | stream, err := CreateChildStream(false) 48 | if err != nil { 49 | return nil, err 50 | } 51 | flags := []string{ 52 | "-y", 53 | // Video format 54 | "-r", fmt.Sprintf("%f", fps), 55 | "-s", fmt.Sprintf("%dx%d", width, height), 56 | "-pix_fmt", "rgb24", "-f", "rawvideo", 57 | // Video input and parameters 58 | "-probesize", "32", "-thread_queue_size", "10000", "-i", stream.ResourceURL(), 59 | } 60 | flags = append(flags, extraFlags...) 61 | flags = append( 62 | flags, 63 | // Output parameters 64 | "-c:v", "libx264", "-preset", "fast", "-crf", "18", 65 | "-pix_fmt", "yuv420p", "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", 66 | ) 67 | flags = append(flags, path) 68 | cmd := exec.Command("ffmpeg", flags...) 69 | cmd.ExtraFiles = stream.ExtraFiles() 70 | if err := cmd.Start(); err != nil { 71 | stream.Cancel() 72 | return nil, err 73 | } 74 | writer, err := stream.Connect() 75 | if err != nil { 76 | cmd.Process.Kill() 77 | return nil, err 78 | } 79 | return &VideoWriter{ 80 | command: cmd, 81 | writer: writer, 82 | width: width, 83 | height: height, 84 | }, nil 85 | } 86 | 87 | // WriteFrame adds a frame to the current video. 88 | func (v *VideoWriter) WriteFrame(img image.Image) error { 89 | bounds := img.Bounds() 90 | if bounds.Dx() != v.width || bounds.Dy() != v.height { 91 | return fmt.Errorf("write frame: image size (%dx%d) does not match video size (%dx%d)", 92 | bounds.Dx(), bounds.Dy(), v.width, v.height) 93 | } 94 | data := make([]byte, 0, 3*v.width*v.height) 95 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 96 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 97 | r, g, b, _ := img.At(x, y).RGBA() 98 | data = append(data, uint8(r>>8), uint8(g>>8), uint8(b>>8)) 99 | } 100 | } 101 | _, err := v.writer.Write(data) 102 | if err != nil { 103 | return errors.Wrap(err, "write frame") 104 | } 105 | return nil 106 | } 107 | 108 | // Close closes the video file and waits for encoding to 109 | // complete. 110 | func (v *VideoWriter) Close() error { 111 | v.writer.Close() 112 | err := v.command.Wait() 113 | if err != nil { 114 | return errors.Wrap(err, "close video writer") 115 | } 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /child_stream.go: -------------------------------------------------------------------------------- 1 | package ffmpego 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "os" 7 | "runtime" 8 | "time" 9 | ) 10 | 11 | // A ChildStream is a connection to an ffmpeg process. 12 | // Typically a ChildStream can only be used for either 13 | // reading or writing, but not both. 14 | // 15 | // If you create a ChildStream, you must either call 16 | // Connect() or Cancel() in order to properly dispose of 17 | // system resources. 18 | // 19 | // Pass the ExtraFiles() to the executed command to ensure 20 | // that it can access the stream. 21 | type ChildStream interface { 22 | // ExtraFiles returns the files that should be passed 23 | // to the executed command in order for it to be able 24 | // to access the stream. 25 | ExtraFiles() []*os.File 26 | 27 | // ResourceURL gets the URL or filename that the child 28 | // process can use to access this stream. 29 | // 30 | // It is intended to be passed as a CLI option. 31 | ResourceURL() string 32 | 33 | // Connect should be called once the sub-process is 34 | // running. If successful, it will return an object 35 | // that maps to the subprocess. 36 | // 37 | // While the return value is a ReadWriter, only either 38 | // Read or Write should be used. 39 | // 40 | // After Connect() is called, you needn't call Cancel() 41 | // on the ChildStream, but must call Close() on the 42 | // returned io.ReadWriteCloser. 43 | Connect() (io.ReadWriteCloser, error) 44 | 45 | // Cancel disposes of resources in this process 46 | // associated with the stream. 47 | // This is only intended to be used if Connect() 48 | // cannot be called. 49 | Cancel() error 50 | } 51 | 52 | // CreateChildStream creates a ChildStream suitable for 53 | // use on the current operating system. 54 | // 55 | // If the reading flag is true, then the stream should be 56 | // read from. Otherwise it should be written to. 57 | func CreateChildStream(reading bool) (ChildStream, error) { 58 | if runtime.GOOS == "windows" { 59 | return NewChildSocketStream() 60 | } else { 61 | return NewChildPipeStream(reading) 62 | } 63 | } 64 | 65 | // A ChildPipeStream uses a pipe to communicate with 66 | // subprocesses. 67 | // 68 | // This is not supported on Windows. 69 | type ChildPipeStream struct { 70 | parentPipe *os.File 71 | childPipe *os.File 72 | } 73 | 74 | // NewChildPipeStream creates a ChildPipeStream. 75 | // 76 | // If the reading flag is true, then the stream should be 77 | // read from. Otherwise it should be written to. 78 | func NewChildPipeStream(reading bool) (*ChildPipeStream, error) { 79 | reader, writer, err := os.Pipe() 80 | if err != nil { 81 | return nil, err 82 | } 83 | if reading { 84 | return &ChildPipeStream{ 85 | parentPipe: reader, 86 | childPipe: writer, 87 | }, nil 88 | } else { 89 | return &ChildPipeStream{ 90 | parentPipe: writer, 91 | childPipe: reader, 92 | }, nil 93 | } 94 | } 95 | 96 | func (c *ChildPipeStream) ExtraFiles() []*os.File { 97 | return []*os.File{c.childPipe} 98 | } 99 | 100 | func (c *ChildPipeStream) ResourceURL() string { 101 | return "pipe:3" 102 | } 103 | 104 | func (c *ChildPipeStream) Connect() (io.ReadWriteCloser, error) { 105 | if err := c.childPipe.Close(); err != nil { 106 | c.parentPipe.Close() 107 | return nil, err 108 | } 109 | return c.parentPipe, nil 110 | } 111 | 112 | func (c *ChildPipeStream) Cancel() error { 113 | c.childPipe.Close() 114 | return c.parentPipe.Close() 115 | } 116 | 117 | // A ChildSocketStream uses a TCP socket to communicate 118 | // with subprocesses. 119 | // 120 | // This should be supported on all operating systems, but 121 | // some may prevent process from listening on sockets. 122 | type ChildSocketStream struct { 123 | listener *net.TCPListener 124 | } 125 | 126 | // NewChildSocketStream creates a ChildSocketStream. 127 | func NewChildSocketStream() (*ChildSocketStream, error) { 128 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 129 | if err != nil { 130 | return nil, err 131 | } 132 | listener, err := net.ListenTCP("tcp", addr) 133 | if err != nil { 134 | return nil, err 135 | } 136 | return &ChildSocketStream{ 137 | listener: listener, 138 | }, nil 139 | } 140 | 141 | func (c *ChildSocketStream) ExtraFiles() []*os.File { 142 | return nil 143 | } 144 | 145 | func (c *ChildSocketStream) ResourceURL() string { 146 | return "tcp://" + c.listener.Addr().String() 147 | } 148 | 149 | func (c *ChildSocketStream) Connect() (io.ReadWriteCloser, error) { 150 | if err := c.listener.SetDeadline(time.Now().Add(time.Second * 10)); err != nil { 151 | c.listener.Close() 152 | return nil, err 153 | } 154 | conn, err := c.listener.Accept() 155 | c.listener.Close() 156 | if err != nil { 157 | return nil, err 158 | } 159 | return conn, nil 160 | } 161 | 162 | func (c *ChildSocketStream) Cancel() error { 163 | return c.listener.Close() 164 | } 165 | -------------------------------------------------------------------------------- /examples/blur_video/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "io" 9 | "log" 10 | "math" 11 | "os" 12 | "runtime" 13 | "sync" 14 | 15 | "github.com/unixpickle/essentials" 16 | "github.com/unixpickle/ffmpego" 17 | ) 18 | 19 | const MaxKernelSum = 0x800000 20 | 21 | func main() { 22 | var blurRadius int 23 | var blurSigma float64 24 | flag.IntVar(&blurRadius, "radius", 5, "the number of pixels for the filter to span") 25 | flag.Float64Var(&blurSigma, "sigma", 2.0, "the blurring standard deviation") 26 | flag.Usage = func() { 27 | fmt.Fprintln(os.Stderr, "Usage: blur_video [flags] ") 28 | fmt.Fprintln(os.Stderr) 29 | fmt.Fprintln(os.Stderr, "Flags:") 30 | flag.PrintDefaults() 31 | os.Exit(1) 32 | } 33 | flag.Parse() 34 | 35 | if len(flag.Args()) != 2 { 36 | flag.Usage() 37 | } 38 | inputFile := flag.Args()[0] 39 | outputFile := flag.Args()[1] 40 | 41 | reader, err := ffmpego.NewVideoReader(inputFile) 42 | essentials.Must(err) 43 | defer reader.Close() 44 | info := reader.VideoInfo() 45 | 46 | writer, err := ffmpego.NewVideoWriterWithAudio( 47 | outputFile, 48 | info.Width, 49 | info.Height, 50 | info.FPS, 51 | inputFile, 52 | ) 53 | essentials.Must(err) 54 | defer writer.Close() 55 | 56 | log.Println("Copying and blurring frames...") 57 | filter := NewGaussianKernel(blurRadius, blurSigma) 58 | for i := 0; true; i++ { 59 | log.Printf("Blurring frame %d...", i+1) 60 | frame, err := reader.ReadFrame() 61 | if err == io.EOF { 62 | break 63 | } 64 | frame = filter.Filter(frame) 65 | essentials.Must(writer.WriteFrame(frame)) 66 | } 67 | } 68 | 69 | type GaussianKernel struct { 70 | Radius int 71 | 72 | // Data stores coefficients for a 1D gaussian, which 73 | // can be applied in both axes to make a 2D gaussian. 74 | Data []uint32 75 | } 76 | 77 | func NewGaussianKernel(radius int, sigma float64) *GaussianKernel { 78 | res := &GaussianKernel{ 79 | Radius: radius, 80 | Data: make([]uint32, radius*2+1), 81 | } 82 | floatGaussian := make([]float64, 0, radius*2+1) 83 | for x := -radius; x <= radius; x++ { 84 | intensity := math.Exp(-float64(x*x) / (sigma * sigma)) 85 | floatGaussian = append(floatGaussian, intensity) 86 | } 87 | 88 | // Make sure we don't overflow uint32 89 | var floatSum float64 90 | for _, x := range floatGaussian { 91 | for _, y := range floatGaussian { 92 | floatSum += x * y 93 | } 94 | } 95 | scale := math.Sqrt(float64(MaxKernelSum)/floatSum) * 0.9999 96 | for i, x := range floatGaussian { 97 | res.Data[i] = uint32(x * scale) 98 | } 99 | 100 | return res 101 | } 102 | 103 | func (g *GaussianKernel) Filter(img image.Image) image.Image { 104 | b := img.Bounds() 105 | width := b.Dx() 106 | height := b.Dy() 107 | 108 | colors := make([][3]uint32, 0, width*height) 109 | for y := b.Min.Y; y < b.Max.Y; y++ { 110 | for x := b.Min.X; x < b.Max.X; x++ { 111 | r, g, b, _ := img.At(x, y).RGBA() 112 | colors = append(colors, [3]uint32{r >> 8, g >> 8, b >> 8}) 113 | } 114 | } 115 | 116 | mapY := func(f func(y int)) { 117 | var wg sync.WaitGroup 118 | numGos := runtime.GOMAXPROCS(0) 119 | for i := 0; i < numGos; i++ { 120 | wg.Add(1) 121 | go func(i int) { 122 | defer wg.Done() 123 | for y := i; y < height; y += numGos { 124 | f(y) 125 | } 126 | }(i) 127 | } 128 | wg.Wait() 129 | } 130 | 131 | sums := make([]uint32, len(colors)) 132 | intermediate := make([][3]uint32, len(colors)) 133 | mapY(func(y int) { 134 | for x := 0; x < width; x++ { 135 | var kernelSum uint32 136 | var colorSum [3]uint32 137 | colorIdx := x + width*(y-g.Radius) 138 | for _, k := range g.Data { 139 | if colorIdx >= 0 && colorIdx < len(colors) { 140 | c := colors[colorIdx] 141 | for i, x := range c { 142 | colorSum[i] += x * k 143 | } 144 | kernelSum += k 145 | } 146 | colorIdx += width 147 | } 148 | idx := x + y*width 149 | intermediate[idx] = colorSum 150 | sums[idx] = kernelSum 151 | } 152 | }) 153 | 154 | result := image.NewRGBA(image.Rect(0, 0, width, height)) 155 | mapY(func(y int) { 156 | startIdx := y * width 157 | endIdx := (y + 1) * width 158 | for x := 0; x < b.Dx(); x++ { 159 | var kernelSum uint32 160 | var colorSum [3]uint32 161 | colorIdx := (x - g.Radius) + y*width 162 | for _, k := range g.Data { 163 | if colorIdx >= startIdx && colorIdx < endIdx { 164 | c := intermediate[colorIdx] 165 | for i, x := range c { 166 | colorSum[i] += x * k 167 | } 168 | kernelSum += k * sums[colorIdx] 169 | } 170 | colorIdx++ 171 | } 172 | for i := range colorSum { 173 | colorSum[i] /= kernelSum 174 | } 175 | result.SetRGBA(x, y, color.RGBA{ 176 | R: uint8(colorSum[0]), 177 | G: uint8(colorSum[1]), 178 | B: uint8(colorSum[2]), 179 | A: 0xff, 180 | }) 181 | } 182 | }) 183 | 184 | return result 185 | } 186 | --------------------------------------------------------------------------------