├── .clang-format ├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── archive_test.go ├── archives.go ├── cover_art.c ├── cover_art.go ├── cover_art.h ├── cover_art_test.go ├── errors.go ├── ffmpeg.c ├── ffmpeg.go ├── ffmpeg.h ├── ffmpeg_test.go ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── meta.c ├── meta.go ├── meta.h ├── meta_test.go ├── mimes.go ├── sanitize_go_1_13.go ├── sanitize_legacy.go ├── testdata ├── alpha.webm ├── exact_thumb_size.jpg ├── invalid_data.jpg ├── jannu_180.jpg ├── jannu_270.jpg ├── jannu_270_h_mirrored.jpg ├── jannu_90.jpg ├── jannu_90_h_mirrored.jpg ├── jannu_baseline.jpg ├── jannu_h_mirrored.jpg ├── jannu_src.png ├── jannu_v_mirrored.jpg ├── meta_segfault.mp4 ├── no_cover.flac ├── no_cover.mp3 ├── no_cover.mp4 ├── no_cover.ogg ├── no_magic.mp3 ├── no_sound.avi ├── no_sound.flv ├── no_sound.mkv ├── no_sound.mov ├── no_sound.mp4 ├── no_sound.ogg ├── no_sound.webm ├── no_sound.wmv ├── no_sound_180.mp4 ├── no_sound_270.mp4 ├── no_sound_90.mp4 ├── non_square.png ├── odd_dimensions.webm ├── rare_brand.mp4 ├── sample.gif ├── sample.jpg ├── sample.png ├── sample.rar ├── sample.txt ├── sample.webp ├── sample.zip ├── segfault.png ├── start_black.webm ├── title.mp3 ├── title.webm ├── too small.png ├── too tall.jpg ├── too wide.jpg ├── with_cover.flac ├── with_cover.mp3 ├── with_sound.avi ├── with_sound.mkv ├── with_sound.mov ├── with_sound.mp4 ├── with_sound.ogg ├── with_sound.webm ├── with_sound_90.mp4 ├── with_sound_hevc.mp4 └── with_sound_vp9.webm ├── thumbnailer.c ├── thumbnailer.go ├── thumbnailer.h ├── thumbnailer_test.go └── util.go /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Webkit 2 | ColumnLimit: 80 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | testdata/*thumb.* 4 | .directory 5 | debug.test 6 | thumbnailer.test 7 | test.exe 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testdata/*thumb.* 3 | .directory 4 | debug.test 5 | thumbnailer.test 6 | test.exe 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: docker 2 | script: 3 | - docker build -t thumbnailer_test . 4 | - docker run --rm thumbnailer_test go test --race 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | WORKDIR /app 4 | 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | RUN apt-get update 7 | RUN apt-get dist-upgrade -y 8 | RUN apt-get install -y \ 9 | build-essential \ 10 | pkg-config \ 11 | curl \ 12 | libavcodec-dev \ 13 | libavutil-dev \ 14 | libavformat-dev \ 15 | libswscale-dev 16 | 17 | # Install Go 18 | RUN curl -s \ 19 | "https://dl.google.com/go/$(curl -s https://golang.org/VERSION?m=text).linux-amd64.tar.gz" \ 20 | | tar xpz -C /usr/local 21 | ENV PATH=$PATH:/usr/local/go/bin 22 | 23 | # Try to cache deps 24 | COPY go.mod . 25 | COPY go.sum . 26 | RUN go mod download 27 | 28 | COPY . . 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | WIN_ARCH=amd64 2 | 3 | # Path to and target for the MXE cross environment for cross-compiling to 4 | # win_amd64. Default value is the debian x86-static install path. 5 | MXE_ROOT=$(HOME)/src/mxe/usr 6 | MXE_TARGET=x86_64-w64-mingw32.static 7 | 8 | clean: 9 | rm -f testdata/*_thumb.* *.exe 10 | 11 | # Cross-compile from Unix into a Windows x86_64 static binary 12 | # Depends on: 13 | # mxe-x86-64-w64-mingw32.static-gcc 14 | # mxe-x86-64-w64-mingw32.static-libidn 15 | # mxe-x86-64-w64-mingw32.static-ffmpeg 16 | # mxe-x86-64-w64-mingw32.static-graphicsmagick 17 | # 18 | # To cross-compile for windows-x86 use: 19 | # make cross_compile_windows WIN_ARCH=386 MXE_TARGET=i686-w64-mingw32.static 20 | cross_tests_windows: 21 | CGO_ENABLED=1 GOOS=windows GOARCH=$(WIN_ARCH) \ 22 | CC=$(MXE_ROOT)/bin/$(MXE_TARGET)-gcc \ 23 | CXX=$(MXE_ROOT)/bin/$(MXE_TARGET)-g++ \ 24 | PKG_CONFIG=$(MXE_ROOT)/bin/$(MXE_TARGET)-pkg-config \ 25 | PKG_CONFIG_LIBDIR=$(MXE_ROOT)/$(MXE_TARGET)/lib/pkgconfig \ 26 | PKG_CONFIG_PATH=$(MXE_ROOT)/$(MXE_TARGET)/lib/pkgconfig \ 27 | go test -a -c -o test.exe --ldflags '-extldflags "-static"' 28 | wine ./test.exe 29 | 30 | test: 31 | go test --race 32 | 33 | test_docker: 34 | docker build -t thumbnailer_test . 35 | docker run \ 36 | --mount type=bind,source="$(PWD)"/testdata,target=/app/testdata \ 37 | --rm thumbnailer_test \ 38 | make clean test testdata_restore_permissions 39 | 40 | testdata_restore_permissions: 41 | chown $(shell stat -c "%u:%g" testdata/alpha.webm) testdata/* 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/bakape/thumbnailer?status.svg)](https://godoc.org/github.com/bakape/thumbnailer) 2 | [![Build Status](https://travis-ci.com/bakape/thumbnailer.svg?branch=master)](https://travis-ci.com/bakape/thumbnailer) 3 | # thumbnailer 4 | Package thumbnailer provides a more efficient media thumbnailer than available 5 | with native Go processing libraries through ffmpeg bindings. 6 | 7 | Use 8 | ``` 9 | go get -u github.com/bakape/thumbnailer/v2 10 | ``` 11 | to install the library in your project. 12 | 13 | For a comprehensive list of file formats supported by default see 14 | main.go:Process(). 15 | 16 | ## Dependencies 17 | * Go >= 1.10 18 | * C11 compiler 19 | * make 20 | * pkg-config 21 | * pthread 22 | * ffmpeg >= 4.1 libraries (libavcodec, libavutil, libavformat, libswscale) 23 | 24 | NB: 25 | * ffmpeg should be compiled with all the dependency libraries for formats you 26 | want to process. On most Linux distributions you should be fine with 27 | the packages in the stock repositories. 28 | * Ubuntu patches to ffmpeg on some Ubuntu versions <19.10 break this library. 29 | In this case, please compile from unmodified ffmpeg sources using: 30 | 31 | ``` 32 | sudo apt build-dep ffmpeg 33 | git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg 34 | cd ffmpeg 35 | git checkout n4.1 36 | ./configure 37 | make -j`nproc` 38 | sudo make install 39 | ``` 40 | -------------------------------------------------------------------------------- /archive_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "sync" 8 | "testing" 9 | ) 10 | 11 | type customReadSeeker struct { 12 | r bytes.Reader 13 | } 14 | 15 | func (c *customReadSeeker) Read(p []byte) (n int, err error) { 16 | return c.r.Read(p) 17 | } 18 | 19 | func (c *customReadSeeker) Seek(offset int64, whence int) (int64, error) { 20 | return c.r.Seek(offset, whence) 21 | } 22 | 23 | func TestArchiveReadSeekerTypes(t *testing.T) { 24 | var wg sync.WaitGroup 25 | 26 | file := openSample(t, "sample.zip") 27 | 28 | buf, err := ioutil.ReadAll(file) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | _, err = file.Seek(0, 0) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | cases := [...]struct { 38 | name string 39 | rs io.ReadSeeker 40 | }{ 41 | {"file", file}, 42 | {"bytes.Reader", bytes.NewReader(buf)}, 43 | {"custom io.ReadSeeker", &customReadSeeker{*bytes.NewReader(buf)}}, 44 | } 45 | 46 | for i := range cases { 47 | c := cases[i] 48 | wg.Add(1) 49 | t.Run(c.name, func(t *testing.T) { 50 | // t.Parallel() 51 | defer wg.Done() 52 | 53 | _, err := processZip(c.rs, &Source{}, Options{}) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | }) 58 | } 59 | 60 | go func() { 61 | wg.Wait() 62 | file.Close() 63 | }() 64 | } 65 | -------------------------------------------------------------------------------- /archives.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "image" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "strings" 11 | 12 | "github.com/nwaples/rardecode" 13 | ) 14 | 15 | const ( 16 | mimeZip = "application/zip" 17 | mime7Zip = "application/x-7z-compressed" 18 | mimeRar = "application/x-rar-compressed" 19 | ) 20 | 21 | // Thumbnail the first image of a zip file 22 | func processZip(rs io.ReadSeeker, src *Source, opts Options, 23 | ) (thumb image.Image, err error) { 24 | // Obtain io.ReaderAt and find out the size of the file 25 | var ( 26 | size int64 27 | ra io.ReaderAt 28 | ) 29 | 30 | useFile := func(f *os.File) (err error) { 31 | info, err := f.Stat() 32 | if err != nil { 33 | return 34 | } 35 | size = info.Size() 36 | ra = f 37 | return 38 | } 39 | 40 | switch rs.(type) { 41 | case *os.File: 42 | err = useFile(rs.(*os.File)) 43 | if err != nil { 44 | return 45 | } 46 | case *bytes.Reader: 47 | r := rs.(*bytes.Reader) 48 | ra = r 49 | size = r.Size() 50 | default: 51 | // Dump exotic io.ReadSeeker to file and use that 52 | var tmp *os.File 53 | tmp, err = ioutil.TempFile("", "") 54 | if err != nil { 55 | return 56 | } 57 | defer os.Remove(tmp.Name()) 58 | defer tmp.Close() 59 | 60 | _, err = io.Copy(tmp, rs) 61 | if err != nil { 62 | return 63 | } 64 | err = useFile(tmp) 65 | if err != nil { 66 | return 67 | } 68 | } 69 | 70 | r, err := zip.NewReader(ra, size) 71 | if err != nil { 72 | return 73 | } 74 | 75 | var ( 76 | imageCount = 0 77 | firstImage *zip.File 78 | ) 79 | // Only check the first 10 files. We don't need to check them all. 80 | for i := 0; i < 10 && i < len(r.File); i++ { 81 | f := r.File[i] 82 | if couldBeImage(f.Name) { 83 | if firstImage == nil { 84 | firstImage = f 85 | } 86 | imageCount++ 87 | } 88 | } 89 | 90 | // If at least 90% of the first 10 files in the archive root are images, 91 | // this is a comic archive 92 | if float32(imageCount)/float32(len(r.File)) >= 0.9 { 93 | src.Mime = "application/vnd.comicbook+zip" 94 | src.Extension = "cbz" 95 | } 96 | 97 | if firstImage == nil { 98 | err = ErrCantThumbnail 99 | return 100 | } 101 | 102 | f, err := firstImage.Open() 103 | if err != nil { 104 | return 105 | } 106 | defer f.Close() 107 | thumb, err = thumbnailArchiveImage(f, opts, size*4) 108 | return 109 | } 110 | 111 | // Returns, if file could be an image file, based on it's extension 112 | func couldBeImage(name string) bool { 113 | if len(name) < 4 { 114 | return false 115 | } 116 | name = strings.ToLower(name[len(name)-4:]) 117 | for _, ext := range [...]string{".png", ".jpg", ".jpeg", ".webp"} { 118 | if strings.HasSuffix(name, ext) { 119 | return true 120 | } 121 | } 122 | return false 123 | } 124 | 125 | // Thumbnail image in from an arechive 126 | func thumbnailArchiveImage(r io.Reader, opts Options, sizeLimit int64, 127 | ) (thumb image.Image, err error) { 128 | // Accept anything we can process 129 | opts.AcceptedMimeTypes = nil 130 | 131 | // Compressed files do not provide seeking. 132 | // Temporary file to conserve RAM. 133 | tmp, err := ioutil.TempFile("", "") 134 | if err != nil { 135 | goto end 136 | } 137 | defer os.Remove(tmp.Name()) 138 | defer tmp.Close() 139 | 140 | // LimitReader protects against decompression bombs 141 | _, err = io.Copy(tmp, io.LimitReader(r, sizeLimit)) 142 | if err != nil { 143 | goto end 144 | } 145 | 146 | _, thumb, err = Process(tmp, opts) 147 | 148 | end: 149 | if err != nil { 150 | err = ErrArchive{err} 151 | } 152 | return 153 | } 154 | 155 | // Thumbnail the first image of a rar file 156 | func processRar(rs io.ReadSeeker, src *Source, opts Options, 157 | ) (thumb image.Image, err error) { 158 | dec, err := rardecode.NewReader(rs, "") 159 | if err != nil { 160 | return 161 | } 162 | 163 | var ( 164 | imageCount = 0 165 | i = 0 166 | h *rardecode.FileHeader 167 | ) 168 | // Only check the first 10 files. We don't need to check them all. 169 | for i = 0; i < 10; i++ { 170 | h, err = dec.Next() 171 | switch err { 172 | case nil: 173 | case io.EOF: 174 | err = nil 175 | goto endLoop 176 | default: 177 | return 178 | } 179 | if couldBeImage(h.Name) { 180 | imageCount++ 181 | if imageCount == 1 { 182 | thumb, err = thumbnailArchiveImage(dec, opts, 100<<20) 183 | if err != nil { 184 | return 185 | } 186 | } 187 | } 188 | } 189 | endLoop: 190 | if thumb == nil { 191 | err = ErrCantThumbnail 192 | return 193 | } 194 | 195 | // If at least 90% of first 10 files in the archive are images, this is a 196 | // comic archive 197 | if float32(imageCount)/float32(i) >= 0.9 { 198 | src.Mime = "application/vnd.comicbook-rar" 199 | src.Extension = "cbr" 200 | } 201 | 202 | return 203 | } 204 | -------------------------------------------------------------------------------- /cover_art.c: -------------------------------------------------------------------------------- 1 | #include "cover_art.h" 2 | 3 | // Extract embedded image 4 | AVPacket retrieve_cover_art(AVFormatContext* ctx) 5 | { 6 | const int i = find_cover_art(ctx); 7 | if (i != -1) { 8 | return ctx->streams[i]->attached_pic; 9 | } 10 | 11 | AVPacket err; 12 | return err; 13 | } 14 | 15 | // Find the first attached picture, if available 16 | int find_cover_art(AVFormatContext* ctx) 17 | { 18 | for (int i = 0; i < ctx->nb_streams; i++) { 19 | const int d = ctx->streams[i]->disposition; 20 | if (d & AV_DISPOSITION_ATTACHED_PIC) { 21 | return i; 22 | } 23 | } 24 | return -1; 25 | } 26 | -------------------------------------------------------------------------------- /cover_art.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #include "cover_art.h" 4 | import "C" 5 | import ( 6 | "bytes" 7 | "image" 8 | "unsafe" 9 | ) 10 | 11 | // HasCoverArt return whether file has cover art in it 12 | func (c *FFContext) HasCoverArt() bool { 13 | return C.find_cover_art(c.avFormatCtx) != -1 14 | } 15 | 16 | // CoverArt extracts any attached image 17 | func (c *FFContext) CoverArt() []byte { 18 | img := C.retrieve_cover_art(c.avFormatCtx) 19 | if img.size <= 0 || img.data == nil { 20 | return nil 21 | } 22 | defer C.free(unsafe.Pointer(img.data)) 23 | return copyCBuffer(C.struct_Buffer{ 24 | data: img.data, 25 | size: C.size_t(img.size), 26 | }) 27 | } 28 | 29 | func processCoverArt(buf []byte, opts Options) (thumb image.Image, err error) { 30 | // Accept anything processable for cover art 31 | opts.AcceptedMimeTypes = nil 32 | 33 | _, thumb, err = Process(bytes.NewReader(buf), opts) 34 | 35 | // Propagate allowed failure errors for retry on the container itself 36 | // and wrap all other errors. 37 | switch err { 38 | case nil: 39 | case ErrTooWide, ErrTooTall, ErrCantThumbnail, ErrGetFrame: 40 | err = ErrCantThumbnail 41 | default: 42 | switch err.(type) { 43 | case ErrUnsupportedMIME, ErrInvalidImage: 44 | err = ErrCantThumbnail 45 | default: 46 | err = ErrCoverArt{err} 47 | } 48 | } 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /cover_art.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include "ffmpeg.h" 3 | 4 | AVPacket retrieve_cover_art(AVFormatContext* ctx); 5 | int find_cover_art(AVFormatContext* ctx); 6 | -------------------------------------------------------------------------------- /cover_art_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestCoverArt(t *testing.T) { 9 | t.Parallel() 10 | 11 | type testCase struct { 12 | file string 13 | has bool 14 | } 15 | 16 | var cases []testCase 17 | for _, f := range samples { 18 | if ignore[f] { 19 | continue 20 | } 21 | cases = append(cases, testCase{f, strings.HasPrefix(f, "with_cover")}) 22 | } 23 | 24 | for i := range cases { 25 | c := cases[i] 26 | t.Run(c.file, func(t *testing.T) { 27 | t.Parallel() 28 | 29 | f := openSample(t, c.file) 30 | defer f.Close() 31 | 32 | ctx, err := NewFFContext(f) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | defer ctx.Close() 37 | 38 | has := ctx.HasCoverArt() 39 | if has != c.has { 40 | if c.file == "with_cover.flac" { 41 | t.Skip("cover art in FLAC is not supported yet") 42 | } 43 | t.Fatal("unexpected cover art presence") 44 | } 45 | if has { 46 | buf := ctx.CoverArt() 47 | if len(buf) == 0 { 48 | t.Fatal("zero length cover art buffer") 49 | } 50 | } 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #include "ffmpeg.h" 4 | import "C" 5 | import ( 6 | "errors" 7 | "fmt" 8 | "io" 9 | ) 10 | 11 | // Thumbnailing errors 12 | var ( 13 | ErrTooWide = ErrInvalidImage("image too wide") 14 | ErrTooTall = ErrInvalidImage("image too tall") 15 | ErrThumbnailingUnknown = errors.New("unknown thumbnailing error") 16 | 17 | // ErrCantThumbnail denotes the input file was valid but no thumbnail could 18 | // be generated for it (example: audio file with no cover art). 19 | ErrCantThumbnail = errors.New("thumbnail can't be generated") 20 | 21 | // ErrGetFrame denotes an unknown failure to retrieve a video frame 22 | ErrGetFrame = errors.New("failed to get frame") 23 | 24 | // ErrStreamNotFound denotes no steam of this media type was found 25 | ErrStreamNotFound = errors.New("no stream of this type found") 26 | ) 27 | 28 | // Indicates the MIME type of the file could not be detected as a supported type 29 | // or was not in the AcceptedMimeTypes list, if defined. 30 | type ErrUnsupportedMIME string 31 | 32 | func (e ErrUnsupportedMIME) Error() string { 33 | return fmt.Sprintf("unsupported MIME type: %s", string(e)) 34 | } 35 | 36 | // Indicates and invalid image has been passed for processing 37 | type ErrInvalidImage string 38 | 39 | func (e ErrInvalidImage) Error() string { 40 | return fmt.Sprintf("invalid image: %s", string(e)) 41 | } 42 | 43 | // ErrorCovert wraps an error that happened during cover art thumbnailing 44 | type ErrCoverArt struct { 45 | Err error 46 | } 47 | 48 | func (e ErrCoverArt) Error() string { 49 | return "cover art: " + e.Err.Error() 50 | } 51 | 52 | // ErrArchive wraps an error that happened during thumbnailing a file in zip 53 | // archive 54 | type ErrArchive struct { 55 | Err error 56 | } 57 | 58 | func (e ErrArchive) Error() string { 59 | return "archive: " + e.Err.Error() 60 | } 61 | 62 | // Cast FFmpeg error to Go error 63 | func castError(err C.int) error { 64 | switch err { 65 | case C.AVERROR_EOF: 66 | return io.EOF 67 | case C.AVERROR_STREAM_NOT_FOUND: 68 | return ErrStreamNotFound 69 | default: 70 | return AVError(err) 71 | } 72 | } 73 | 74 | // AVError converts an FFmpeg error code to a Go error with a human-readable 75 | // error message 76 | type AVError C.int 77 | 78 | // Error formats the FFmpeg error in human-readable format 79 | func (f AVError) Error() string { 80 | buf := C.malloc(1024) 81 | defer C.free(buf) 82 | C.av_strerror(C.int(f), (*C.char)(buf), 1024) 83 | return fmt.Sprintf("ffmpeg: %s", C.GoString((*C.char)(buf))) 84 | } 85 | 86 | // Code returns the underlying AVERROR error code 87 | func (f AVError) Code() C.int { 88 | return C.int(f) 89 | } 90 | -------------------------------------------------------------------------------- /ffmpeg.c: -------------------------------------------------------------------------------- 1 | #include "ffmpeg.h" 2 | 3 | static const int bufSize = 1 << 12; 4 | 5 | static pthread_mutex_t codecMu = PTHREAD_MUTEX_INITIALIZER; 6 | 7 | void init(void) 8 | { 9 | #if LIBAVCODEC_VERSION_INT < AV_VERSION_INT(58, 9, 100) 10 | av_register_all(); 11 | #endif 12 | #if LIBAVCODEC_VERSION_INT < AV_VERSION_INT(58, 10, 100) 13 | avcodec_register_all(); 14 | #endif 15 | av_log_set_level(16); 16 | } 17 | 18 | int create_context(AVFormatContext** ctx, const char* input_format) 19 | { 20 | unsigned char* buf = malloc(bufSize); 21 | AVFormatContext* c = *ctx; 22 | 23 | c->pb = avio_alloc_context( 24 | buf, bufSize, 0, c, readCallBack, NULL, seekCallBack); 25 | c->flags |= AVFMT_FLAG_CUSTOM_IO | AVFMT_FLAG_DISCARD_CORRUPT; 26 | 27 | AVInputFormat* avif = NULL; 28 | if (input_format) { 29 | avif = av_find_input_format(input_format); 30 | } 31 | int err = avformat_open_input(ctx, NULL, avif, NULL); 32 | if (err < 0) { 33 | return err; 34 | } 35 | 36 | // Calls avcodec_open2 internally, so needs locking 37 | pthread_mutex_lock(&codecMu); 38 | err = avformat_find_stream_info(*ctx, NULL); 39 | pthread_mutex_unlock(&codecMu); 40 | return err; 41 | } 42 | 43 | int codec_context(AVCodecContext** avcc, int* stream, AVFormatContext* avfc, 44 | const enum AVMediaType type) 45 | { 46 | int err; 47 | AVStream* st = NULL; 48 | AVCodec* codec = NULL; 49 | 50 | *stream = av_find_best_stream(avfc, type, -1, -1, NULL, 0); 51 | if (*stream < 0) { 52 | return *stream; 53 | } 54 | st = avfc->streams[*stream]; 55 | 56 | // ffvp8/9 doesn't support alpha channel so force libvpx. 57 | switch (st->codecpar->codec_id) { 58 | case AV_CODEC_ID_VP8: 59 | codec = avcodec_find_decoder_by_name("libvpx"); 60 | break; 61 | case AV_CODEC_ID_VP9: 62 | codec = avcodec_find_decoder_by_name("libvpx-vp9"); 63 | break; 64 | } 65 | if (!codec) { 66 | codec = avcodec_find_decoder(st->codecpar->codec_id); 67 | if (!codec) { 68 | return -1; 69 | } 70 | } 71 | 72 | *avcc = avcodec_alloc_context3(codec); 73 | if (!*avcc) { 74 | goto end; 75 | } 76 | err = avcodec_parameters_to_context(*avcc, st->codecpar); 77 | if (err < 0) { 78 | goto end; 79 | } 80 | 81 | // Not thread safe. Needs lock. 82 | pthread_mutex_lock(&codecMu); 83 | err = avcodec_open2(*avcc, codec, NULL); 84 | pthread_mutex_unlock(&codecMu); 85 | 86 | end: 87 | if (err < 0 && *avcc) { 88 | avcodec_free_context(avcc); 89 | *avcc = NULL; 90 | } 91 | return err; 92 | } 93 | -------------------------------------------------------------------------------- /ffmpeg.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #cgo pkg-config: libavcodec libavutil libavformat libswscale 4 | // #cgo CFLAGS: -std=c11 -g 5 | // #include "ffmpeg.h" 6 | import "C" 7 | import ( 8 | "errors" 9 | "fmt" 10 | "io" 11 | "sync" 12 | "time" 13 | "unsafe" 14 | ) 15 | 16 | // FFMediaType correspond to the AVMediaType enum in ffmpeg 17 | type FFMediaType int8 18 | 19 | // Correspond to the AVMediaType enum in ffmpeg 20 | const ( 21 | FFUnknown FFMediaType = iota - 1 22 | FFVideo 23 | FFAudio 24 | ) 25 | 26 | var ( 27 | // Global map of AVIOHandlers. One handlers struct per format context. 28 | // Using AVFormatContext pointer address as a key. 29 | handlersMap = handlerMap{ 30 | m: make(map[uintptr]io.ReadSeeker), 31 | } 32 | 33 | // Input format specifiers for FFmpeg. These save FFmpeg some overhead on 34 | // format detection and also prevent failure to open input on format 35 | // detection failure. 36 | inputFormats = map[string]*C.char{ 37 | "image/jpeg": C.CString("mjpeg"), 38 | "image/png": C.CString("image2"), 39 | "image/gif": C.CString("gif"), 40 | "image/webp": C.CString("webp"), 41 | "application/ogg": C.CString("ogg"), 42 | "video/webm": C.CString("webm"), 43 | "video/x-matroska": C.CString("matroska"), 44 | "video/mp4": C.CString("mp4"), 45 | "video/avi": C.CString("avi"), 46 | "video/quicktime": C.CString("mp4"), 47 | "video/x-flv": C.CString("flv"), 48 | "audio/mpeg": C.CString("mp3"), 49 | "audio/aac": C.CString("aac"), 50 | "audio/wave": C.CString("wav"), 51 | "audio/x-flac": C.CString("flac"), 52 | } 53 | ) 54 | 55 | // C can not retain any pointers to Go memory after the cgo call returns. We 56 | // still need a way to bind AVFormatContext instances to Go I/O functions. To do 57 | // that we convert the AVFormatContext pointer to a uintptr and use it as a key 58 | // to look up the respective handlers on each call. 59 | type handlerMap struct { 60 | sync.RWMutex 61 | m map[uintptr]io.ReadSeeker 62 | } 63 | 64 | func (h *handlerMap) Set(k uintptr, rs io.ReadSeeker) { 65 | h.Lock() 66 | h.m[k] = rs 67 | h.Unlock() 68 | } 69 | 70 | func (h *handlerMap) Delete(k uintptr) { 71 | h.Lock() 72 | delete(h.m, k) 73 | h.Unlock() 74 | } 75 | 76 | func (h *handlerMap) Get(k unsafe.Pointer) io.ReadSeeker { 77 | h.RLock() 78 | handlers, ok := h.m[uintptr(k)] 79 | h.RUnlock() 80 | if !ok { 81 | panic(fmt.Errorf("no handler instance found for pointer: %v", k)) 82 | } 83 | return handlers 84 | } 85 | 86 | // Container for allocated codecs, so we can reuse them 87 | type codecInfo struct { 88 | stream C.int 89 | ctx *C.AVCodecContext 90 | } 91 | 92 | // FFContext is a wrapper for passing Go I/O interfaces to C 93 | type FFContext struct { 94 | avFormatCtx *C.struct_AVFormatContext 95 | handlerKey uintptr 96 | codecs map[FFMediaType]codecInfo 97 | } 98 | 99 | // NewFFContext constructs a new AVIOContext and AVFormatContext. 100 | // It is the responsibility of the caller to call Close() after finishing 101 | // using the context. 102 | func NewFFContext(rs io.ReadSeeker) (*FFContext, error) { 103 | return newFFContextWithFormat(rs, nil) 104 | } 105 | 106 | // Like NewFFContext, but optionally specifies the passed input format explicitly. 107 | // inputFormat can be NULL. 108 | func newFFContextWithFormat(rs io.ReadSeeker, inputFormat *C.char, 109 | ) (*FFContext, error) { 110 | ctx := C.avformat_alloc_context() 111 | this := &FFContext{ 112 | avFormatCtx: ctx, 113 | codecs: make(map[FFMediaType]codecInfo), 114 | } 115 | 116 | this.handlerKey = uintptr(unsafe.Pointer(ctx)) 117 | handlersMap.Set(this.handlerKey, rs) 118 | 119 | err := C.create_context(&this.avFormatCtx, inputFormat) 120 | if err < 0 { 121 | this.Close() 122 | return nil, castError(err) 123 | } 124 | if this.avFormatCtx == nil { 125 | this.Close() 126 | return nil, errors.New("unknown context creation error") 127 | } 128 | 129 | return this, nil 130 | } 131 | 132 | // Close closes and frees memory allocated for c. c should not be used after 133 | // this point. 134 | func (c *FFContext) Close() { 135 | for _, ci := range c.codecs { 136 | C.avcodec_free_context(&ci.ctx) 137 | } 138 | if c.avFormatCtx != nil { 139 | C.av_free(unsafe.Pointer(c.avFormatCtx.pb.buffer)) 140 | c.avFormatCtx.pb.buffer = nil 141 | C.av_free(unsafe.Pointer(c.avFormatCtx.pb)) 142 | C.av_free(unsafe.Pointer(c.avFormatCtx)) 143 | } 144 | handlersMap.Delete(c.handlerKey) 145 | } 146 | 147 | // Allocate a codec context for the best stream of the passed FFMediaType, if 148 | // not allocated already 149 | func (c *FFContext) codecContext(typ FFMediaType) (codecInfo, error) { 150 | if ci, ok := c.codecs[typ]; ok { 151 | return ci, nil 152 | } 153 | 154 | var ( 155 | ctx *C.struct_AVCodecContext 156 | stream C.int 157 | ) 158 | err := C.codec_context(&ctx, &stream, c.avFormatCtx, int32(typ)) 159 | switch { 160 | case err == C.AVERROR_STREAM_NOT_FOUND: 161 | return codecInfo{}, ErrStreamNotFound 162 | case err < 0: 163 | return codecInfo{}, castError(err) 164 | } 165 | 166 | ci := codecInfo{ 167 | stream: stream, 168 | ctx: ctx, 169 | } 170 | c.codecs[typ] = ci 171 | return ci, nil 172 | } 173 | 174 | // CodecName returns the codec name of the best stream of type typ 175 | func (c *FFContext) CodecName(typ FFMediaType) (codec string, err error) { 176 | ci, err := c.codecContext(typ) 177 | if err != nil { 178 | return 179 | } 180 | return C.GoString(ci.ctx.codec.name), nil 181 | } 182 | 183 | // HasStream returns, if the file has a decodeable stream of the passed type 184 | func (c *FFContext) HasStream(typ FFMediaType) (bool, error) { 185 | _, err := c.codecContext(typ) 186 | switch err { 187 | case nil: 188 | return true, nil 189 | case ErrStreamNotFound: 190 | return false, nil 191 | default: 192 | return false, err 193 | } 194 | } 195 | 196 | // Length returns the duration of the input 197 | func (c *FFContext) Length() time.Duration { 198 | return time.Duration(c.avFormatCtx.duration * 1000) 199 | } 200 | 201 | // Dims returns dimensions of the best video (or image) stream in the media 202 | func (c *FFContext) Dims() (dims Dims, err error) { 203 | ci, err := c.codecContext(FFVideo) 204 | if err != nil { 205 | return 206 | } 207 | dims = Dims{ 208 | Width: uint(ci.ctx.width), 209 | Height: uint(ci.ctx.height), 210 | } 211 | return 212 | } 213 | 214 | func castIOError(err error) C.int { 215 | // Properly pass EOF to FFmpeg 216 | if err == io.EOF { 217 | return C.AVERROR_EOF 218 | } 219 | return -1 220 | } 221 | 222 | //export readCallBack 223 | func readCallBack(opaque unsafe.Pointer, buf *C.uint8_t, bufSize C.int) C.int { 224 | s := (*[1 << 30]byte)(unsafe.Pointer(buf))[:bufSize:bufSize] 225 | n, err := handlersMap.Get(opaque).Read(s) 226 | if err != nil && !(err == io.EOF && n != 0) { 227 | return castIOError(err) 228 | } 229 | return C.int(n) 230 | } 231 | 232 | //export seekCallBack 233 | func seekCallBack( 234 | opaque unsafe.Pointer, 235 | offset C.int64_t, 236 | whence C.int, 237 | ) C.int64_t { 238 | n, err := handlersMap.Get(opaque).Seek(int64(offset), int(whence)) 239 | if err != nil { 240 | return C.int64_t(castIOError(err)) 241 | } 242 | return C.int64_t(n) 243 | } 244 | -------------------------------------------------------------------------------- /ffmpeg.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | #include 4 | 5 | extern int readCallBack(void*, uint8_t*, int); 6 | extern int64_t seekCallBack(void*, int64_t, int); 7 | 8 | // Initialize FFmpeg 9 | void init(void); 10 | 11 | // Initialize am AVFormatContext with the buffered file/ 12 | // input_format can be NULL. 13 | int create_context(AVFormatContext** ctx, const char* input_format); 14 | 15 | // Create a AVCodecContext of the desired media type 16 | int codec_context(AVCodecContext** avcc, int* stream, AVFormatContext* avfc, 17 | const enum AVMediaType type); 18 | -------------------------------------------------------------------------------- /ffmpeg_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestDims(t *testing.T) { 9 | t.Parallel() 10 | 11 | type testCase struct { 12 | name string 13 | dims Dims 14 | } 15 | 16 | var cases []testCase 17 | var c testCase 18 | for _, f := range samples { 19 | if ignore[f] { 20 | continue 21 | } 22 | c.name = f 23 | switch { 24 | case f == "with_cover.mp3": 25 | c.dims = Dims{1280, 720} 26 | case f == "sample.gif": 27 | c.dims = Dims{584, 720} 28 | case strings.HasPrefix(f, "no_cover"), 29 | strings.HasPrefix(f, "with_cover"): 30 | c.dims = Dims{0, 0} 31 | case strings.HasPrefix(f, "sample"): 32 | c.dims = Dims{1280, 720} 33 | default: 34 | continue 35 | } 36 | cases = append(cases, c) 37 | } 38 | 39 | opts := Options{ 40 | ThumbDims: Dims{150, 150}, 41 | } 42 | 43 | for i := range cases { 44 | c := cases[i] 45 | t.Run(c.name, func(t *testing.T) { 46 | t.Parallel() 47 | 48 | f := openSample(t, c.name) 49 | defer f.Close() 50 | 51 | src, _, err := Process(f, opts) 52 | switch err { 53 | case nil: 54 | case ErrCantThumbnail: 55 | default: 56 | t.Fatal(err) 57 | } 58 | if src.Dims != c.dims { 59 | t.Fatalf("%v != %v", src.Dims, c.dims) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bakape/thumbnailer/v2 2 | 3 | go 1.13 4 | 5 | require github.com/nwaples/rardecode v1.1.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= 2 | github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package thumbnailer provides a more efficient media thumbnailer than \ 2 | // available with native Go processing libraries through ffmpeg bindings. 3 | package thumbnailer 4 | 5 | import ( 6 | "image" 7 | "io" 8 | "time" 9 | ) 10 | 11 | // Source stores information about the source file 12 | type Source struct { 13 | // Some containers may or may not have either 14 | HasAudio, HasVideo bool 15 | 16 | // Length of the stream. Applies to audio and video files. 17 | Length time.Duration 18 | 19 | // Source dimensions, if file is image or video 20 | Dims 21 | 22 | // Mime type of the source file 23 | Mime string 24 | 25 | // Canonical file extension 26 | Extension string 27 | 28 | // Codec of the source file when applicable 29 | Codec string 30 | 31 | // Optional metadata 32 | Meta 33 | } 34 | 35 | // File metadata 36 | type Meta struct { 37 | Title, Artist string 38 | } 39 | 40 | // Dims store the dimensions of an image 41 | type Dims struct { 42 | Width, Height uint 43 | } 44 | 45 | // Options suplied to the Thumbnail function 46 | type Options struct { 47 | // Maximum source image dimensions. Any image exceeding either will be 48 | // rejected and return with ErrTooTall or ErrTooWide. 49 | // If not set, all image processing will not be restricted by that 50 | // dimension. 51 | MaxSourceDims Dims 52 | 53 | // Target Maximum dimensions for the thumbnail. 54 | // 55 | // This defines the bounding box of the thumbnail. The thumbnail will be 56 | // scaled down until both dimensions fit the bounding box. 57 | // To scale only by one dimension, specify the other as math.MaxUint32. 58 | // 59 | // Defaults to 150x150, if unset. 60 | ThumbDims Dims 61 | 62 | // MIME types to accept for thumbnailing. 63 | // If nil, all MIME types will be processed. 64 | // 65 | // To process MIME types that are a subset of archive files, like 66 | // "application/x-cbz", "application/x-cbr", "application/x-cb7" and 67 | // "application/x-cbt", you must accept the corresponding archive type 68 | // such as "application/zip" or leave this nil. 69 | AcceptedMimeTypes map[string]bool 70 | } 71 | 72 | // Process generates a thumbnail from a file of unknown type and performs some 73 | // basic meta information extraction 74 | func Process(rs io.ReadSeeker, opts Options) ( 75 | src Source, thumb image.Image, err error, 76 | ) { 77 | if opts.ThumbDims.Width == 0 { 78 | opts.ThumbDims.Width = 150 79 | } 80 | if opts.ThumbDims.Height == 0 { 81 | opts.ThumbDims.Height = 150 82 | } 83 | 84 | src.Mime, src.Extension, err = DetectMIME(rs, opts.AcceptedMimeTypes) 85 | if err != nil { 86 | return 87 | } 88 | _, err = rs.Seek(0, 0) 89 | if err != nil { 90 | return 91 | } 92 | 93 | // TODO: PDF Processing 94 | // TODO: SVG processing 95 | 96 | var fn Processor 97 | 98 | override := overrideProcessors[src.Mime] 99 | if override != nil { 100 | fn = override 101 | } else { 102 | switch src.Mime { 103 | case 104 | "image/jpeg", 105 | "image/png", 106 | "image/gif", 107 | "image/webp", 108 | "application/ogg", 109 | "video/webm", 110 | "video/x-matroska", 111 | "video/mp4", 112 | "video/avi", 113 | "video/quicktime", 114 | "video/x-ms-wmv", 115 | "video/x-flv", 116 | "audio/mpeg", 117 | "audio/aac", 118 | "audio/wave", 119 | "audio/x-flac", 120 | "audio/midi": 121 | fn = processMedia 122 | case mimeZip: 123 | fn = processZip 124 | case mimeRar: 125 | fn = processRar 126 | default: 127 | err = ErrUnsupportedMIME(src.Mime) 128 | return 129 | } 130 | } 131 | 132 | thumb, err = fn(rs, &src, opts) 133 | switch src.Mime { 134 | case "image/jpeg", 135 | "image/png", 136 | "image/gif", 137 | "image/webp": 138 | // FFmpeg considers images to be video for processing reasons 139 | src.HasVideo = false 140 | } 141 | return 142 | } 143 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/png" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | var samples = [...]string{ 13 | "no_cover.mp4", 14 | "no_sound.mkv", 15 | "no_sound.ogg", 16 | "sample.gif", 17 | "with_sound.avi", 18 | "no_cover.flac", 19 | "no_cover.ogg", 20 | "no_sound.mov", 21 | "no_sound.webm", 22 | "with_sound_vp9.webm", 23 | "sample.jpg", 24 | "with_cover.mp3", 25 | "with_sound.mkv", 26 | "with_sound.ogg", 27 | "no_sound.avi", 28 | "no_sound.mp4", 29 | "no_sound.wmv", 30 | "no_sound_90.mp4", 31 | "no_sound_180.mp4", 32 | "no_sound_270.mp4", 33 | "sample.webp", 34 | "with_sound.mov", 35 | "with_sound.webm", 36 | "no_cover.mp3", 37 | "no_magic.mp3", // No magic numbers 38 | "no_sound.flv", 39 | "sample.png", 40 | "with_cover.flac", 41 | "with_sound.mp4", 42 | "with_sound_90.mp4", 43 | "with_sound_hevc.mp4", 44 | "odd_dimensions.webm", // Unconventional dims for a YUV stream 45 | "alpha.webm", 46 | "start_black.webm", // Check the histogram thumbnailing 47 | "rare_brand.mp4", 48 | "invalid_data.jpg", // Check handling images with some invalid data 49 | "sample.zip", 50 | "sample.rar", 51 | "too small.png", 52 | "exact_thumb_size.jpg", 53 | "meta_segfault.mp4", 54 | 55 | // Exif rotation compensation 56 | "jannu_baseline.jpg", 57 | "jannu_h_mirrored.jpg", 58 | "jannu_180.jpg", 59 | "jannu_v_mirrored.jpg", 60 | "jannu_270_h_mirrored.jpg", 61 | "jannu_90.jpg", 62 | "jannu_90_h_mirrored.jpg", 63 | "jannu_270.jpg", 64 | } 65 | 66 | var ignore = map[string]bool{ 67 | "invalid_data.jpg": true, 68 | "sample.zip": true, 69 | "sample.rar": true, 70 | } 71 | 72 | func TestProcess(t *testing.T) { 73 | t.Parallel() 74 | 75 | opts := Options{ 76 | ThumbDims: Dims{150, 150}, 77 | } 78 | 79 | for i := range samples { 80 | sample := samples[i] 81 | t.Run(sample, func(t *testing.T) { 82 | t.Parallel() 83 | 84 | f := openSample(t, sample) 85 | defer f.Close() 86 | 87 | src, thumb, err := Process(f, opts) 88 | if err != nil && err != ErrCantThumbnail { 89 | t.Fatal(err) 90 | } 91 | 92 | if err != ErrCantThumbnail { 93 | name := fmt.Sprintf(`%s_thumb.png`, sample) 94 | writeSample(t, name, thumb) 95 | } 96 | 97 | t.Logf("src: %v\n", src) 98 | if thumb != nil { 99 | t.Logf("thumb: %v\t\n", thumb.Bounds()) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | func openSample(t *testing.T, name string) *os.File { 106 | t.Helper() 107 | 108 | f, err := os.Open(filepath.Join("testdata", name)) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | return f 113 | } 114 | 115 | func writeSample(t *testing.T, name string, img image.Image) { 116 | t.Helper() 117 | 118 | f, err := os.Create(filepath.Join("testdata", name)) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | defer f.Close() 123 | 124 | png.Encode(f, img) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | } 129 | 130 | func TestErrorPassing(t *testing.T) { 131 | t.Parallel() 132 | 133 | f := openSample(t, "sample.txt") 134 | defer f.Close() 135 | 136 | _, _, err := Process(f, Options{ 137 | ThumbDims: Dims{ 138 | Width: 150, 139 | Height: 150, 140 | }, 141 | }) 142 | if err == nil { 143 | t.Fatal(`expected error`) 144 | } 145 | } 146 | 147 | func TestSourceAlreadyThumbSize(t *testing.T) { 148 | t.Parallel() 149 | 150 | f := openSample(t, "too small.png") 151 | defer f.Close() 152 | 153 | _, thumb, err := Process(f, Options{ 154 | ThumbDims: Dims{ 155 | Width: 150, 156 | Height: 150, 157 | }, 158 | }) 159 | if err != nil { 160 | t.Fatal(err) 161 | } 162 | dims := thumb.Bounds().Max 163 | if dims.X != 121 { 164 | t.Errorf("unexpected width: 121 : %d", dims.X) 165 | } 166 | if dims.Y != 150 { 167 | t.Errorf("unexpected height: 150: %d", dims.Y) 168 | } 169 | } 170 | 171 | func TestUnprocessedLine(t *testing.T) { 172 | t.Parallel() 173 | 174 | const sample = "jannu_180.jpg" 175 | f := openSample(t, sample) 176 | defer f.Close() 177 | 178 | _, thumb, err := Process(f, Options{ 179 | ThumbDims: Dims{ 180 | Width: 300, 181 | Height: 300, 182 | }, 183 | }) 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | name := fmt.Sprintf(`%s_to_300x300_thumb.png`, sample) 188 | writeSample(t, name, thumb) 189 | } 190 | -------------------------------------------------------------------------------- /meta.c: -------------------------------------------------------------------------------- 1 | #include "meta.h" 2 | 3 | // Find artist and title meta info if present 4 | struct Meta retrieve_meta(AVFormatContext* ctx) 5 | { 6 | AVDictionary* meta = ctx->metadata; 7 | AVDictionaryEntry* tag; 8 | struct Meta meta_out = { .title = NULL, .artist = NULL }; 9 | if (!meta) { 10 | return meta_out; 11 | } 12 | 13 | tag = av_dict_get(meta, "title", NULL, 0); 14 | if (tag != NULL) { 15 | meta_out.title = tag->value; 16 | } 17 | tag = av_dict_get(meta, "artist", NULL, 0); 18 | if (tag != NULL) { 19 | meta_out.artist = tag->value; 20 | } 21 | 22 | return meta_out; 23 | } 24 | -------------------------------------------------------------------------------- /meta.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #include "meta.h" 4 | import "C" 5 | 6 | // Meta retrieves title and artist for source, if present 7 | func (c *FFContext) Meta() (m Meta) { 8 | meta := C.retrieve_meta(c.avFormatCtx) 9 | if meta.title != nil { 10 | m.Title = C.GoString(meta.title) 11 | sanitize(&m.Title) 12 | } 13 | if meta.artist != nil { 14 | m.Artist = C.GoString(meta.artist) 15 | sanitize(&m.Artist) 16 | } 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /meta.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include "ffmpeg.h" 3 | #include 4 | #include 5 | 6 | struct Meta { 7 | const char const* title; 8 | const char const* artist; 9 | }; 10 | 11 | struct Meta retrieve_meta(AVFormatContext* ctx); 12 | -------------------------------------------------------------------------------- /meta_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import "testing" 4 | 5 | func TestMetadataExtraction(t *testing.T) { 6 | t.Parallel() 7 | 8 | f := openSample(t, "title.mp3") 9 | defer f.Close() 10 | 11 | src, _, err := Process(f, Options{}) 12 | if err != nil && err != ErrCantThumbnail { 13 | t.Fatal(err) 14 | } 15 | if src.Artist != "Test Artist" { 16 | t.Errorf("unexpected artist: Test Artist : %s", src.Artist) 17 | } 18 | if src.Title != "Test Title" { 19 | t.Errorf("unexpected title: Test Title: %s", src.Title) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mimes.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "image" 7 | "io" 8 | ) 9 | 10 | const sniffSize = 4 << 10 11 | 12 | // Matching code partially adapted from "net/http/sniff.go" 13 | 14 | // Mime type prefix magic number matchers and canonical extensions 15 | var matchers = []Matcher{ 16 | // Probably most common types, this library will be used for, first. 17 | // More expensive checks are also positioned lower. 18 | &exactSig{"jpg", "image/jpeg", []byte("\xFF\xD8\xFF")}, 19 | &exactSig{"png", "image/png", []byte("\x89\x50\x4E\x47\x0D\x0A\x1A\x0A")}, 20 | &exactSig{"gif", "image/gif", []byte("GIF87a")}, 21 | &exactSig{"gif", "image/gif", []byte("GIF89a")}, 22 | &maskedSig{ 23 | "webp", 24 | "image/webp", 25 | []byte("\xFF\xFF\xFF\xFF\x00\x00\x00\x00\xFF\xFF\xFF\xFF\xFF\xFF"), 26 | []byte("RIFF\x00\x00\x00\x00WEBPVP"), 27 | }, 28 | &maskedSig{ 29 | "ogg", 30 | "application/ogg", 31 | []byte("OggS\x00"), 32 | []byte("\x4F\x67\x67\x53\x00"), 33 | }, 34 | MatcherFunc(matchWebmOrMKV), 35 | &exactSig{"pdf", "application/pdf", []byte("%PDF-")}, 36 | &maskedSig{ 37 | "mp3", 38 | "audio/mpeg", 39 | []byte("\xFF\xFF\xFF"), 40 | []byte("ID3"), 41 | }, 42 | &exactSig{"mp3", "audio/mpeg", []byte("\xFF\xFB")}, 43 | MatcherFunc(matchMP4), 44 | &exactSig{"aac", "audio/aac", []byte("ÿñ")}, 45 | &exactSig{"aac", "audio/aac", []byte("ÿù")}, 46 | &exactSig{"bmp", "image/bmp", []byte("BM")}, 47 | &maskedSig{ 48 | "wav", 49 | "audio/wave", 50 | []byte("\xFF\xFF\xFF\xFF\x00\x00\x00\x00\xFF\xFF\xFF\xFF"), 51 | []byte("RIFF\x00\x00\x00\x00WAVE"), 52 | }, 53 | &maskedSig{ 54 | "avi", 55 | "video/avi", 56 | []byte("\xFF\xFF\xFF\xFF\x00\x00\x00\x00\xFF\xFF\xFF\xFF"), 57 | []byte("RIFF\x00\x00\x00\x00AVI "), 58 | }, 59 | &exactSig{"psd", "image/photoshop", []byte("8BPS")}, 60 | &exactSig{"flac", "audio/x-flac", []byte("fLaC")}, 61 | &exactSig{"tiff", "image/tiff", []byte("II*\x00")}, 62 | &exactSig{"tiff", "image/tiff", []byte("MM\x00*")}, 63 | &exactSig{"mov", "video/quicktime", []byte("\x00\x00\x00\x14ftyp")}, 64 | &exactSig{ 65 | "wmv", 66 | "video/x-ms-wmv", 67 | []byte{0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, 0xA6, 0xD9}, 68 | }, 69 | &exactSig{"flv", "video/x-flv", []byte("FLV\x01")}, 70 | &exactSig{"ico", "image/x-icon", []byte("\x00\x00\x01\x00")}, 71 | &maskedSig{ 72 | "midi", 73 | "audio/midi", 74 | []byte("\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF"), 75 | []byte("MThd\x00\x00\x00\x06"), 76 | }, 77 | &exactSig{"zip", mimeZip, []byte("\x50\x4B\x03\x04")}, 78 | &exactSig{"rar", mimeRar, []byte("\x52\x61\x72\x20\x1A\x07\x00")}, 79 | 80 | // RAR v5 archive 81 | &exactSig{"rar", mimeRar, []byte("\x52\x61\x72\x21\x1A\x07\x01\x00")}, 82 | 83 | &exactSig{"7z", mime7Zip, []byte{'7', 'z', 0xBC, 0xAF, 0x27, 0x1C}}, 84 | } 85 | 86 | var ( 87 | // User-defined MIME type processors 88 | overrideProcessors = map[string]Processor{} 89 | ) 90 | 91 | // Processor is a specialized file processor for a specific file type. 92 | // Returns thumbnail and error. 93 | // 94 | // io.ReadSeeker is the start position, when passed to Processor. 95 | type Processor func(io.ReadSeeker, *Source, Options) (image.Image, error) 96 | 97 | // Matcher takes up to the first 4 KB of a file and returns the MIME type 98 | // and canonical extension, that were matched. Empty string indicates no match. 99 | type Matcher interface { 100 | Match([]byte) (mime string, extension string) 101 | } 102 | 103 | // MatcherFunc is an adapter that allows using functions as Matcher 104 | type MatcherFunc func([]byte) (string, string) 105 | 106 | // Match implements Matcher 107 | func (fn MatcherFunc) Match(data []byte) (string, string) { 108 | return fn(data) 109 | } 110 | 111 | type exactSig struct { 112 | ext, mime string 113 | sig []byte 114 | } 115 | 116 | func (e *exactSig) Match(data []byte) (string, string) { 117 | if bytes.HasPrefix(data, e.sig) { 118 | return e.mime, e.ext 119 | } 120 | return "", "" 121 | } 122 | 123 | type maskedSig struct { 124 | ext, mime string 125 | mask, pat []byte 126 | } 127 | 128 | func (m *maskedSig) Match(data []byte) (string, string) { 129 | if len(data) < len(m.mask) { 130 | return "", "" 131 | } 132 | for i, mask := range m.mask { 133 | db := data[i] & mask 134 | if db != m.pat[i] { 135 | return "", "" 136 | } 137 | } 138 | return m.mime, m.ext 139 | } 140 | 141 | func matchWebmOrMKV(data []byte) (string, string) { 142 | switch { 143 | case len(data) < 8 || !bytes.HasPrefix(data, []byte("\x1A\x45\xDF\xA3")): 144 | return "", "" 145 | case bytes.Contains(data[4:], []byte("webm")): 146 | return "video/webm", "webm" 147 | case bytes.Contains(data[4:], []byte("matroska")): 148 | return "video/x-matroska", "mkv" 149 | default: 150 | return "", "" 151 | } 152 | } 153 | 154 | func matchMP4(data []byte) (string, string) { 155 | if len(data) < 12 { 156 | return "", "" 157 | } 158 | 159 | boxSize := int(binary.BigEndian.Uint32(data[:4])) 160 | nope := boxSize%4 != 0 || 161 | len(data) < boxSize || 162 | !bytes.Equal(data[4:8], []byte("ftyp")) 163 | if nope { 164 | return "", "" 165 | } 166 | 167 | for st := 8; st < boxSize; st += 4 { 168 | if st == 12 { 169 | // minor version number 170 | continue 171 | } 172 | if bytes.Equal(data[st:st+3], []byte("mp4")) || 173 | bytes.Equal(data[st:st+4], []byte("dash")) { 174 | return "video/mp4", "mp4" 175 | } 176 | } 177 | return "", "" 178 | } 179 | 180 | // MP3 is a retarded standard, that will not always even have a magic number. 181 | // Need to detect with FFMPEG as a last resort. 182 | func matchMP3(data []byte) (mime string, ext string) { 183 | c, err := NewFFContext(bytes.NewReader(data)) 184 | if err != nil { 185 | return 186 | } 187 | defer c.Close() 188 | 189 | codec, err := c.CodecName(FFAudio) 190 | if err != nil { 191 | return 192 | } 193 | if codec == "mp3" || codec == "mp3float" { 194 | return "audio/mpeg", "mp3" 195 | } 196 | return 197 | } 198 | 199 | // RegisterMatcher adds an extra magic prefix-based MIME type matcher to the 200 | // default set with an included canonical file extension. 201 | // 202 | // Not safe to use concurrently with file processing. 203 | func RegisterMatcher(m Matcher) { 204 | matchers = append(matchers, m) 205 | } 206 | 207 | // RegisterProcessor registers a file processor for a specific MIME type. 208 | // Can be used to add support for additional MIME types or as an override. 209 | // 210 | // Not safe to use concurrently with file processing. 211 | func RegisterProcessor(mime string, fn Processor) { 212 | overrideProcessors[mime] = fn 213 | } 214 | 215 | // DetectMIME detects the MIME typ of the rs. 216 | // 217 | // accepted: if not nil, specifies MIME types to not reject with 218 | // ErrUnsupportedMIME 219 | func DetectMIME(rs io.ReadSeeker, accepted map[string]bool, 220 | ) ( 221 | mime, ext string, err error, 222 | ) { 223 | _, err = rs.Seek(0, 0) 224 | if err != nil { 225 | return 226 | } 227 | buf := make([]byte, sniffSize) 228 | read, err := rs.Read(buf) 229 | if err != nil { 230 | return 231 | } 232 | if read < sniffSize { 233 | buf = buf[:read] 234 | } 235 | 236 | for _, m := range matchers { 237 | mime, ext = m.Match(buf) 238 | if mime != "" { 239 | break 240 | } 241 | } 242 | 243 | if mime == "" { 244 | if accepted == nil || accepted["audio/mpeg"] { 245 | mime, ext = matchMP3(buf) 246 | } 247 | } 248 | 249 | switch { 250 | case mime == "": 251 | err = ErrUnsupportedMIME("application/octet-stream") 252 | // Check if MIME is accepted, if specified 253 | case accepted != nil && !accepted[mime]: 254 | err = ErrUnsupportedMIME(mime) 255 | } 256 | return 257 | } 258 | -------------------------------------------------------------------------------- /sanitize_go_1_13.go: -------------------------------------------------------------------------------- 1 | // +build go1.13 2 | 3 | package thumbnailer 4 | 5 | import ( 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | // Convert string to valid UTF-8. 11 | // Strings passed from C are not guaranteed to be valid. 12 | func sanitize(s *string) { 13 | if !utf8.ValidString(*s) { 14 | // Need to replace invalid UTF-8 with a valid UTF-8 marker 15 | *s = strings.ToValidUTF8(*s, "?") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sanitize_legacy.go: -------------------------------------------------------------------------------- 1 | // +build !go1.13 2 | 3 | package thumbnailer 4 | 5 | import ( 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | // Convert string to valid UTF-8. 11 | // Strings passed from C are not guaranteed to be valid. 12 | func sanitize(s *string) { 13 | if !utf8.ValidString(*s) { 14 | *s = strings.Map( 15 | func(r rune) rune { 16 | if r == utf8.RuneError { 17 | // Need to replace invalid UTF-8 with a valid UTF-8 marker 18 | return '?' 19 | } 20 | return r 21 | }, 22 | *s, 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /testdata/alpha.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/alpha.webm -------------------------------------------------------------------------------- /testdata/exact_thumb_size.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/exact_thumb_size.jpg -------------------------------------------------------------------------------- /testdata/invalid_data.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/invalid_data.jpg -------------------------------------------------------------------------------- /testdata/jannu_180.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_180.jpg -------------------------------------------------------------------------------- /testdata/jannu_270.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_270.jpg -------------------------------------------------------------------------------- /testdata/jannu_270_h_mirrored.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_270_h_mirrored.jpg -------------------------------------------------------------------------------- /testdata/jannu_90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_90.jpg -------------------------------------------------------------------------------- /testdata/jannu_90_h_mirrored.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_90_h_mirrored.jpg -------------------------------------------------------------------------------- /testdata/jannu_baseline.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_baseline.jpg -------------------------------------------------------------------------------- /testdata/jannu_h_mirrored.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_h_mirrored.jpg -------------------------------------------------------------------------------- /testdata/jannu_src.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_src.png -------------------------------------------------------------------------------- /testdata/jannu_v_mirrored.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/jannu_v_mirrored.jpg -------------------------------------------------------------------------------- /testdata/meta_segfault.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/meta_segfault.mp4 -------------------------------------------------------------------------------- /testdata/no_cover.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_cover.flac -------------------------------------------------------------------------------- /testdata/no_cover.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_cover.mp3 -------------------------------------------------------------------------------- /testdata/no_cover.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_cover.mp4 -------------------------------------------------------------------------------- /testdata/no_cover.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_cover.ogg -------------------------------------------------------------------------------- /testdata/no_magic.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_magic.mp3 -------------------------------------------------------------------------------- /testdata/no_sound.avi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.avi -------------------------------------------------------------------------------- /testdata/no_sound.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.flv -------------------------------------------------------------------------------- /testdata/no_sound.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.mkv -------------------------------------------------------------------------------- /testdata/no_sound.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.mov -------------------------------------------------------------------------------- /testdata/no_sound.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.mp4 -------------------------------------------------------------------------------- /testdata/no_sound.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.ogg -------------------------------------------------------------------------------- /testdata/no_sound.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.webm -------------------------------------------------------------------------------- /testdata/no_sound.wmv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound.wmv -------------------------------------------------------------------------------- /testdata/no_sound_180.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound_180.mp4 -------------------------------------------------------------------------------- /testdata/no_sound_270.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound_270.mp4 -------------------------------------------------------------------------------- /testdata/no_sound_90.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/no_sound_90.mp4 -------------------------------------------------------------------------------- /testdata/non_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/non_square.png -------------------------------------------------------------------------------- /testdata/odd_dimensions.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/odd_dimensions.webm -------------------------------------------------------------------------------- /testdata/rare_brand.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/rare_brand.mp4 -------------------------------------------------------------------------------- /testdata/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.gif -------------------------------------------------------------------------------- /testdata/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.jpg -------------------------------------------------------------------------------- /testdata/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.png -------------------------------------------------------------------------------- /testdata/sample.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.rar -------------------------------------------------------------------------------- /testdata/sample.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.txt -------------------------------------------------------------------------------- /testdata/sample.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.webp -------------------------------------------------------------------------------- /testdata/sample.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/sample.zip -------------------------------------------------------------------------------- /testdata/segfault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/segfault.png -------------------------------------------------------------------------------- /testdata/start_black.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/start_black.webm -------------------------------------------------------------------------------- /testdata/title.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/title.mp3 -------------------------------------------------------------------------------- /testdata/title.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/title.webm -------------------------------------------------------------------------------- /testdata/too small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/too small.png -------------------------------------------------------------------------------- /testdata/too tall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/too tall.jpg -------------------------------------------------------------------------------- /testdata/too wide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/too wide.jpg -------------------------------------------------------------------------------- /testdata/with_cover.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_cover.flac -------------------------------------------------------------------------------- /testdata/with_cover.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_cover.mp3 -------------------------------------------------------------------------------- /testdata/with_sound.avi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.avi -------------------------------------------------------------------------------- /testdata/with_sound.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.mkv -------------------------------------------------------------------------------- /testdata/with_sound.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.mov -------------------------------------------------------------------------------- /testdata/with_sound.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.mp4 -------------------------------------------------------------------------------- /testdata/with_sound.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.ogg -------------------------------------------------------------------------------- /testdata/with_sound.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound.webm -------------------------------------------------------------------------------- /testdata/with_sound_90.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound_90.mp4 -------------------------------------------------------------------------------- /testdata/with_sound_hevc.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound_hevc.mp4 -------------------------------------------------------------------------------- /testdata/with_sound_vp9.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bakape/thumbnailer/30dee7c8c8b10c2495916bbb1e3cf573fdaf9560/testdata/with_sound_vp9.webm -------------------------------------------------------------------------------- /thumbnailer.c: -------------------------------------------------------------------------------- 1 | #include "thumbnailer.h" 2 | #include 3 | #include 4 | #include 5 | 6 | /** 7 | * Potential thumbnail lookup filter to reduce the risk of an inappropriate 8 | * selection (such as a black frame) we could get with an absolute seek. 9 | * 10 | * Simplified version of algorithm by Vadim Zaliva . 11 | * http://notbrainsurgery.livejournal.com/29773.html 12 | * 13 | * Adapted by Janis Petersons 14 | */ 15 | 16 | #define HIST_SIZE 256 17 | #define HIST_CHANNELS 3 18 | #define MAX_FRAMES 10 19 | 20 | // Compute sum-square deviation to estimate "closeness" 21 | static double compute_error(const unsigned hist[HIST_SIZE][HIST_CHANNELS], 22 | const double average[HIST_SIZE][HIST_CHANNELS]) 23 | { 24 | double sum_sq_err = 0; 25 | for (int i = 0; i < HIST_SIZE; i++) { 26 | for (int j = 0; j < HIST_CHANNELS; j++) { 27 | const double err = average[i][j] - (double)hist[i][j]; 28 | sum_sq_err += err * err; 29 | } 30 | } 31 | return sum_sq_err; 32 | } 33 | 34 | // Select best frame based on RGB histograms 35 | static AVFrame* select_best_frame(AVFrame* frames[], int size) 36 | { 37 | if (size == 1) { 38 | return frames[0]; 39 | } 40 | 41 | // RGB color distribution histograms of the frames 42 | unsigned hists[MAX_FRAMES][HIST_SIZE][HIST_CHANNELS] = { 0 }; 43 | 44 | // Compute each frame's histogram 45 | for (int frame_i = 0; frame_i < size; frame_i++) { 46 | const AVFrame* f = frames[frame_i]; 47 | const int line_size = f->linesize[0]; 48 | const uint8_t* p = f->data[0]; 49 | for (int i = 0; i < f->height; i++) { 50 | const int offset = line_size * i; 51 | for (int j = 0; j < line_size; j++) { 52 | // Count amount of pixels in each channel. 53 | // Using modulo to account for frames in non-3-byte pixel 54 | // formats. 55 | hists[frame_i][p[offset + j]][j % HIST_CHANNELS]++; 56 | } 57 | } 58 | } 59 | 60 | // Average all histograms 61 | double average[HIST_SIZE][HIST_CHANNELS] = { 0 }; 62 | for (int i = 0; i < size; i++) { 63 | for (int j = 0; j < HIST_SIZE; j++) { 64 | // Unrolled for less data dependency 65 | average[i][0] += (double)hists[i][j][0]; 66 | average[i][1] += (double)hists[i][j][1]; 67 | average[i][2] += (double)hists[i][j][2]; 68 | } 69 | // Unrolled for less data dependency 70 | average[i][0] /= size; 71 | average[i][1] /= size; 72 | average[i][2] /= size; 73 | } 74 | 75 | // Find the frame closer to the average using the sum of squared errors 76 | double min_sq_err = DBL_MAX; 77 | int best_i = 0; 78 | for (int i = 0; i < size; i++) { 79 | const double sq_err = compute_error(hists[i], average); 80 | if (sq_err < min_sq_err) { 81 | best_i = i; 82 | min_sq_err = sq_err; 83 | } 84 | } 85 | return frames[best_i]; 86 | } 87 | 88 | // Calculate size and allocate buffer 89 | static void alloc_buffer(struct Buffer* dst) 90 | { 91 | dst->size 92 | = av_image_get_buffer_size(AV_PIX_FMT_RGBA, dst->width, dst->height, 1); 93 | dst->data = malloc(dst->size); 94 | } 95 | 96 | // Use point subsampling to scale image up to target size and convert to RGBA 97 | static int resample(struct Buffer* dst, const AVFrame const* frame) 98 | { 99 | struct SwsContext* ctx 100 | = sws_getContext(frame->width, frame->height, frame->format, dst->width, 101 | dst->height, AV_PIX_FMT_RGBA, SWS_POINT, NULL, NULL, NULL); 102 | if (!ctx) { 103 | return AVERROR(ENOMEM); 104 | } 105 | 106 | uint8_t* dst_data[1] = { dst->data }; // RGB have one plane 107 | int dst_linesize[1] = { 4 * dst->width }; // RGBA stride 108 | 109 | sws_scale(ctx, (const uint8_t* const*)frame->data, frame->linesize, 0, 110 | frame->height, dst_data, dst_linesize); 111 | 112 | sws_freeContext(ctx); 113 | return 0; 114 | } 115 | 116 | struct Pixel { 117 | // uint16_t fits the max value of 255 * 16 118 | uint16_t r, g, b, a; 119 | }; 120 | 121 | // Downscale resampled image 122 | static void downscale(struct Buffer* dst, const struct Buffer const* src) 123 | { 124 | // First sum all pixels into a multidimensional array 125 | const size_t size = dst->height * dst->width * sizeof(struct Pixel); 126 | struct Pixel(*img)[dst->width] = malloc(size); 127 | memset(img, 0, size); 128 | 129 | int i = 0; 130 | for (int y = 0; y < src->height; y++) { 131 | const int dest_y = y ? y / 4 : 0; 132 | for (int x = 0; x < src->width; x++) { 133 | struct Pixel* p = &img[dest_y][x ? x / 4 : 0]; 134 | 135 | // Skip pixels with maxed transparency 136 | const uint8_t alpha = src->data[i + 3]; 137 | if (alpha != 0) { 138 | // Unrolled for less data dependency 139 | p->r += src->data[i]; 140 | p->g += src->data[i + 1]; 141 | p->b += src->data[i + 2]; 142 | p->a += alpha; 143 | } 144 | 145 | i += 4; // Less data dependency than i++ 146 | } 147 | } 148 | 149 | // Then average them and arrange as RGBA 150 | i = 0; 151 | for (int y = 0; y < dst->height; y++) { 152 | for (int x = 0; x < dst->width; x++) { 153 | const struct Pixel p = img[y][x]; 154 | 155 | // Unrolled for less data dependency 156 | dst->data[i] = p.r ? p.r / 16 : 0; 157 | dst->data[i + 1] = p.g ? p.g / 16 : 0; 158 | dst->data[i + 2] = p.b ? p.b / 16 : 0; 159 | dst->data[i + 3] = p.a ? p.a / 16 : 0; 160 | i += 4; 161 | } 162 | } 163 | } 164 | 165 | // Decrease intensity of pixels with alpha 166 | static void compensate_alpha(struct Buffer* img) 167 | { 168 | int i = 0; 169 | for (int y = 0; y < img->height; y++) { 170 | for (int x = 0; x < img->width; x++) { 171 | const uint8_t alpha = img->data[i + 3]; 172 | if (alpha != 255) { 173 | const float scale = (float)alpha / (float)255; 174 | for (int j = 0; j < 3; j++) { 175 | float val = (float)img->data[i + j] * scale; 176 | if (val > 255) { 177 | val = 255; 178 | } 179 | img->data[i + j] = (uint8_t)val; 180 | } 181 | } 182 | i += 4; 183 | } 184 | } 185 | } 186 | 187 | // Swap 2 RGBA pixels by memory addresses 188 | static inline void swap_pixels(size_t a, size_t b) 189 | { 190 | uint8_t tmp[4]; 191 | memcpy(&tmp, (void*)a, 4); 192 | memcpy((void*)a, (void*)b, 4); 193 | memcpy((void*)b, tmp, 4); 194 | } 195 | 196 | static void mirror_horizontally(struct Buffer* img) 197 | { 198 | for (size_t y = 0; y < img->height; y++) { 199 | size_t left = (size_t)img->data + y * img->width * 4; 200 | size_t right = left + (img->width - 1) * 4; 201 | for (size_t x = 0; x < img->width / 2; x++) { 202 | swap_pixels(left, right); 203 | left += 4; 204 | right -= 4; 205 | } 206 | } 207 | } 208 | 209 | static void mirror_vertically(struct Buffer* img) 210 | { 211 | const size_t row_size = img->width * 4; 212 | 213 | for (size_t y = 0; y < img->height / 2; y++) { 214 | size_t top = (size_t)img->data + row_size * y; 215 | size_t bottom = (size_t)img->data + row_size * (img->height - 1 - y); 216 | for (size_t x = 0; x < img->width; x++) { 217 | swap_pixels(top, bottom); 218 | top += 4; 219 | bottom += 4; 220 | } 221 | } 222 | } 223 | 224 | // Rotate an image by 180 degrees 225 | static void rotate_180(struct Buffer* img) 226 | { 227 | const size_t row_size = img->width * 4; 228 | 229 | for (size_t y = 0; y < img->height / 2; y++) { 230 | size_t top = (size_t)img->data + row_size * y; 231 | size_t bottom = (size_t)img->data + row_size * (img->height - y) - 4; 232 | for (size_t x = 0; x < img->width; x++) { 233 | swap_pixels(top, bottom); 234 | top += 4; 235 | bottom -= 4; 236 | } 237 | } 238 | 239 | if (img->height % 2) { 240 | const size_t off = (size_t)img->data + row_size * (img->height / 2); 241 | for (int x = 0; x < img->width / 2; x++) { 242 | swap_pixels(off + x * 4, off + (img->width - x - 1) * 4); 243 | } 244 | } 245 | } 246 | 247 | // Swap buffers and dimensions after a 90 degree rotation in any direction 248 | static void finish_90_rotation(struct Buffer* img, uint8_t* out) 249 | { 250 | free(img->data); 251 | img->data = out; 252 | uint64_t tmp = img->width; 253 | img->width = img->height; 254 | img->height = tmp; 255 | } 256 | 257 | // Copy RGBA pixel from src to dst 258 | static inline void copy_pixel(size_t dst, size_t src) 259 | { 260 | memcpy((void*)dst, (void*)src, 4); 261 | } 262 | 263 | // Rotate an image by 90 degrees clockwise 264 | static void rotate_90(struct Buffer* img) 265 | { 266 | uint8_t* out = malloc(img->size); 267 | 268 | size_t src = (size_t)img->data; 269 | for (size_t y = 0; y < img->height; y++) { 270 | for (size_t x = 0; x < img->width; x++) { 271 | copy_pixel( 272 | (size_t)out + img->height * 4 * x + (img->height - y - 1) * 4, 273 | src); 274 | src += 4; 275 | } 276 | } 277 | 278 | finish_90_rotation(img, out); 279 | } 280 | 281 | // Rotate an image by 270 degrees clockwise 282 | static void rotate_270(struct Buffer* img) 283 | { 284 | uint8_t* out = malloc(img->size); 285 | 286 | size_t src = (size_t)img->data; 287 | for (size_t y = 0; y < img->height; y++) { 288 | for (size_t x = 0; x < img->width; x++) { 289 | copy_pixel( 290 | (size_t)out + img->height * 4 * (img->width - x - 1) + y * 4, 291 | src); 292 | src += 4; 293 | } 294 | } 295 | 296 | finish_90_rotation(img, out); 297 | } 298 | 299 | // Rotate or flip according to exif orientation as the thumbnail does not have 300 | // any metadata 301 | static void adjust_orientation(struct Buffer* img, const int orientation) 302 | { 303 | switch (orientation) { 304 | case 2: 305 | mirror_horizontally(img); 306 | break; 307 | case 3: 308 | rotate_180(img); 309 | break; 310 | case 4: 311 | mirror_vertically(img); 312 | break; 313 | case 7: 314 | mirror_horizontally(img); 315 | case 6: 316 | rotate_90(img); 317 | break; 318 | case 5: 319 | mirror_horizontally(img); 320 | case 8: 321 | rotate_270(img); 322 | break; 323 | } 324 | } 325 | 326 | // Scale both image dimensions to fit in constraint, if it is exceeded 327 | static void scale_dims(struct Buffer* img, uint64_t max, uint64_t val) 328 | { 329 | if (val > max) { 330 | // Maintains aspect ratio 331 | const double scale = (double)val / (double)max; 332 | img->width = (uint64_t)((double)img->width / scale); 333 | img->height = (uint64_t)((double)img->height / scale); 334 | } 335 | } 336 | 337 | // Encode and scale frame to RGBA image 338 | static int encode_frame( 339 | struct Buffer* img, AVFrame* frame, const struct Dims box, int orientation) 340 | { 341 | int err; 342 | 343 | if (frame->metadata) { 344 | AVDictionaryEntry* e 345 | = av_dict_get(frame->metadata, "Orientation", NULL, 0); 346 | if (e) { 347 | orientation = atol(e->value); 348 | } 349 | } 350 | 351 | img->width = frame->width; 352 | img->height = frame->height; 353 | 354 | // If image fits inside thumbnail, simply convert to RGBA. 355 | // 356 | // scale_dims() does not work, if image size is exactly that of the target 357 | // thumbnail size. Perhaps a peculiarity of sws_scale(). 358 | if (img->width < box.width && img->height < box.height) { 359 | alloc_buffer(img); 360 | err = resample(img, frame); 361 | if (err) { 362 | return err; 363 | } 364 | compensate_alpha(img); 365 | adjust_orientation(img, orientation); 366 | return 0; 367 | } 368 | 369 | scale_dims(img, box.width, img->width); 370 | scale_dims(img, box.height, img->height); 371 | 372 | // Subsample to 4 times the thumbnail size and then Box subsample that. 373 | // A decent enough compromise between quality and performance for images 374 | // around the thumbnail size and much bigger ones. 375 | struct Buffer enlarged 376 | = { .width = img->width * 4, .height = img->height * 4 }; 377 | alloc_buffer(&enlarged); 378 | err = resample(&enlarged, frame); 379 | if (err) { 380 | free(enlarged.data); 381 | return err; 382 | } 383 | alloc_buffer(img); 384 | downscale(img, &enlarged); 385 | free(enlarged.data); 386 | adjust_orientation(img, orientation); 387 | return err; 388 | } 389 | 390 | // Read from stream until a full frame is read 391 | static int read_frame(AVFormatContext* avfc, AVCodecContext* avcc, 392 | AVFrame* frame, const int stream) 393 | { 394 | int err = 0; 395 | AVPacket pkt; 396 | 397 | // Continue until frame read 398 | while (1) { 399 | err = av_read_frame(avfc, &pkt); 400 | if (err) { 401 | goto end; 402 | } 403 | 404 | if (pkt.stream_index == stream) { 405 | err = avcodec_send_packet(avcc, &pkt); 406 | if (err < 0) { 407 | goto end; 408 | } 409 | 410 | err = avcodec_receive_frame(avcc, frame); 411 | switch (err) { 412 | case 0: 413 | goto end; 414 | case AVERROR(EAGAIN): 415 | av_packet_unref(&pkt); 416 | break; 417 | default: 418 | goto end; 419 | } 420 | } 421 | } 422 | 423 | end: 424 | av_packet_unref(&pkt); 425 | return err; 426 | } 427 | 428 | int generate_thumbnail(struct Buffer* img, AVFormatContext* avfc, 429 | AVCodecContext* avcc, const int stream, const struct Dims thumb_dims) 430 | { 431 | int err = 0; 432 | int size = 0; 433 | int i = 0; 434 | AVFrame* frames[MAX_FRAMES] = { NULL }; 435 | AVFrame* next = NULL; 436 | 437 | // Read up to 10 frames 438 | while (1) { 439 | next = av_frame_alloc(); 440 | err = read_frame(avfc, avcc, next, stream); 441 | if (err) { 442 | goto end; 443 | } 444 | 445 | // Analyze only every 3rd frame to cover a larger time frame 446 | if (!(i++ % 3)) { 447 | frames[size++] = next; 448 | next = NULL; 449 | if (size == MAX_FRAMES) { 450 | goto end; 451 | } 452 | } else { 453 | av_frame_free(&next); 454 | } 455 | } 456 | 457 | end: 458 | if (size) { 459 | int orientation = 0; 460 | AVDictionaryEntry* e 461 | = av_dict_get(avfc->streams[stream]->metadata, "rotate", NULL, 0); 462 | if (e) { 463 | switch (atol(e->value)) { 464 | case 90: 465 | orientation = 6; 466 | break; 467 | case 180: 468 | orientation = 3; 469 | break; 470 | case 270: 471 | orientation = 8; 472 | break; 473 | } 474 | } 475 | 476 | // Ignore all read errors, if at least one frame read 477 | err = encode_frame( 478 | img, select_best_frame(frames, size), thumb_dims, orientation); 479 | } 480 | 481 | for (int i = 0; i < size; i++) { 482 | av_frame_free(&frames[i]); 483 | } 484 | if (next) { 485 | av_frame_free(&next); 486 | } 487 | 488 | return err; 489 | } 490 | -------------------------------------------------------------------------------- /thumbnailer.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #include "thumbnailer.h" 4 | // #include 5 | import "C" 6 | import ( 7 | "image" 8 | "io" 9 | "unsafe" 10 | ) 11 | 12 | func init() { 13 | C.av_log_set_level(C.AV_LOG_ERROR) 14 | } 15 | 16 | // Thumbnail generates a thumbnail from a representative frame of the media. 17 | // Images count as one frame media. 18 | func (c *FFContext) Thumbnail(dims Dims) (thumb image.Image, err error) { 19 | ci, err := c.codecContext(FFVideo) 20 | if err != nil { 21 | return 22 | } 23 | 24 | var img C.struct_Buffer 25 | defer func() { 26 | if img.data != nil { 27 | C.free(unsafe.Pointer(img.data)) 28 | } 29 | }() 30 | ret := C.generate_thumbnail(&img, c.avFormatCtx, ci.ctx, ci.stream, 31 | C.struct_Dims{ 32 | width: C.uint64_t(dims.Width), 33 | height: C.uint64_t(dims.Height), 34 | }) 35 | switch { 36 | case ret != 0: 37 | err = castError(ret) 38 | case img.data == nil: 39 | err = ErrGetFrame 40 | default: 41 | thumb = &image.RGBA{ 42 | Pix: copyCBuffer(img), 43 | Stride: 4 * int(img.width), 44 | Rect: image.Rectangle{ 45 | Max: image.Point{ 46 | X: int(img.width), 47 | Y: int(img.height), 48 | }, 49 | }, 50 | } 51 | } 52 | return 53 | } 54 | 55 | func processMedia(rs io.ReadSeeker, src *Source, opts Options, 56 | ) ( 57 | thumb image.Image, err error, 58 | ) { 59 | c, err := newFFContextWithFormat(rs, inputFormats[src.Mime]) 60 | if err != nil { 61 | return 62 | } 63 | defer c.Close() 64 | 65 | src.Length = c.Length() 66 | src.Meta = c.Meta() 67 | src.HasAudio, err = c.HasStream(FFAudio) 68 | if err != nil { 69 | return 70 | } 71 | src.HasVideo, err = c.HasStream(FFVideo) 72 | if err != nil { 73 | return 74 | } 75 | if src.HasVideo { 76 | src.Dims, err = c.Dims() 77 | if err != nil { 78 | return 79 | } 80 | } 81 | 82 | if c.HasCoverArt() { 83 | thumb, err = processCoverArt(c.CoverArt(), opts) 84 | switch err { 85 | case nil: 86 | return 87 | case ErrCantThumbnail: 88 | // Try again on the container itself, if cover art thumbnailing 89 | // fails 90 | default: 91 | return 92 | } 93 | } 94 | 95 | if src.HasVideo { 96 | max := opts.MaxSourceDims 97 | if max.Width != 0 && src.Width > max.Width { 98 | err = ErrTooWide 99 | return 100 | } 101 | if max.Height != 0 && src.Height > max.Height { 102 | err = ErrTooTall 103 | return 104 | } 105 | src.Codec, err = c.CodecName(FFVideo) 106 | if err != nil { 107 | return 108 | } 109 | thumb, err = c.Thumbnail(opts.ThumbDims) 110 | } else { 111 | err = ErrCantThumbnail 112 | } 113 | return 114 | } 115 | -------------------------------------------------------------------------------- /thumbnailer.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include "ffmpeg.h" 3 | 4 | struct Buffer { 5 | uint8_t* data; 6 | size_t size; 7 | uint64_t width, height; 8 | }; 9 | 10 | struct Dims { 11 | uint64_t width, height; 12 | }; 13 | 14 | // Writes RGBA thumbnail buffer to img 15 | int generate_thumbnail(struct Buffer* img, AVFormatContext* avfc, 16 | AVCodecContext* avcc, const int stream, const struct Dims thumb_dims); 17 | -------------------------------------------------------------------------------- /thumbnailer_test.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestDimensionValidation(t *testing.T) { 9 | t.Parallel() 10 | 11 | cases := [...]struct { 12 | name, file string 13 | maxW, maxH uint 14 | err error 15 | }{ 16 | { 17 | name: "width check disabled", 18 | file: "too wide.jpg", 19 | }, 20 | { 21 | name: "too wide", 22 | file: "too wide.jpg", 23 | maxW: 2000, 24 | err: ErrTooWide, 25 | }, 26 | { 27 | name: "height check disabled", 28 | file: "too tall.jpg", 29 | }, 30 | { 31 | name: "too tall", 32 | file: "too tall.jpg", 33 | maxH: 2000, 34 | err: ErrTooTall, 35 | }, 36 | } 37 | 38 | for i := range cases { 39 | c := cases[i] 40 | t.Run(c.name, func(t *testing.T) { 41 | t.Parallel() 42 | 43 | opts := Options{ 44 | ThumbDims: Dims{ 45 | Width: 150, 46 | Height: 150, 47 | }, 48 | MaxSourceDims: Dims{ 49 | Width: c.maxW, 50 | Height: c.maxH, 51 | }, 52 | } 53 | 54 | f := openSample(t, c.file) 55 | defer f.Close() 56 | 57 | _, _, err := Process(f, opts) 58 | if err != c.err { 59 | t.Fatalf("unexpected error: `%s` : `%s`", c.err, err) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestDimensionConstraints(t *testing.T) { 66 | t.Parallel() 67 | 68 | cases := [...]struct { 69 | name string 70 | constr Dims 71 | }{ 72 | { 73 | name: "square", 74 | constr: Dims{ 75 | Width: 200, 76 | Height: 200, 77 | }, 78 | }, 79 | { 80 | name: "rect tall", 81 | constr: Dims{ 82 | Width: 100, 83 | Height: 200, 84 | }, 85 | }, 86 | { 87 | name: "rect wide", 88 | constr: Dims{ 89 | Width: 200, 90 | Height: 100, 91 | }, 92 | }, 93 | } 94 | 95 | for i := range cases { 96 | c := cases[i] 97 | t.Run(c.name, func(t *testing.T) { 98 | t.Parallel() 99 | 100 | f := openSample(t, "non_square.png") 101 | defer f.Close() 102 | 103 | _, thumb, err := Process(f, Options{ 104 | ThumbDims: c.constr, 105 | }) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | m := thumb.Bounds().Max 111 | if uint(m.X) > c.constr.Width || uint(m.Y) > c.constr.Height { 112 | t.Fatalf( 113 | "thumbnail exceeds bounds: %+v not inside %+v", 114 | m, 115 | c.constr, 116 | ) 117 | } 118 | 119 | writeSample( 120 | t, 121 | fmt.Sprintf( 122 | "non_square.png_%dx%d_thumb.png", 123 | c.constr.Width, c.constr.Height, 124 | ), 125 | thumb, 126 | ) 127 | }) 128 | } 129 | } 130 | 131 | func TestStackOverflow(t *testing.T) { 132 | t.Parallel() 133 | 134 | f := openSample(t, "segfault.png") 135 | defer f.Close() 136 | 137 | _, _, err := Process(f, Options{ 138 | ThumbDims: Dims{ 139 | Width: 800, 140 | Height: 1700, 141 | }, 142 | }) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package thumbnailer 2 | 3 | // #include "thumbnailer.h" 4 | // #include "string.h" 5 | import "C" 6 | import "unsafe" 7 | 8 | // Copy a C buffer into a Go buffer from the pool 9 | func copyCBuffer(src C.struct_Buffer) []byte { 10 | buf := make([]byte, int(src.size)) 11 | C.memcpy(unsafe.Pointer(&buf[0]), unsafe.Pointer(src.data), src.size) 12 | return buf 13 | } 14 | --------------------------------------------------------------------------------