diff --git a/CachingThumbnailer.go b/CachingThumbnailer.go new file mode 100644 index 0000000..390408f --- /dev/null +++ b/CachingThumbnailer.go @@ -0,0 +1,47 @@ +package thumbnail + +import ( + lru "github.com/hashicorp/golang-lru" +) + +type CachingThumbnailer struct { + t Thumbnailer + cache *lru.Cache // threadsafe, might be nil +} + +func NewCachingThumbnailer(cacheSize int, opts *Config) (Thumbnailer, error) { + + upstream := NewThumbnailerEx(opts) + + if cacheSize == 0 { + return upstream, nil + } + + thumbCache, err := lru.New(cacheSize) + if err != nil { + return nil, err + } + + return &CachingThumbnailer{ + t: upstream, + cache: thumbCache, + }, nil +} + +func (this *CachingThumbnailer) RenderFile(absPath string) ([]byte, error) { + + thumb, ok := this.cache.Get(absPath) + if ok { + return thumb.([]byte), nil + } + + // Add to cache + rendered, err := this.t.RenderFile(absPath) + if err != nil { + return nil, err + } + + this.cache.Add(absPath, rendered) + + return rendered, nil +} diff --git a/DirectThumbnailer.go b/DirectThumbnailer.go new file mode 100644 index 0000000..2d1e516 --- /dev/null +++ b/DirectThumbnailer.go @@ -0,0 +1,42 @@ +package thumbnail + +import ( + "mime" + "path/filepath" + "strings" +) + +type DirectThumbnailer struct { + cfg Config +} + +func NewThumbnailer(w, h int) Thumbnailer { + opts := DefaultConfig + opts.Width = w + opts.Height = h + + return NewThumbnailerEx(&opts) +} + +func NewThumbnailerEx(opts *Config) Thumbnailer { + if opts == nil { + opts = &DefaultConfig + } + + return &DirectThumbnailer{cfg: *opts} +} + +func (this *DirectThumbnailer) RenderFile(absPath string) ([]byte, error) { + mimeType := mime.TypeByExtension(filepath.Ext(absPath)) + + if strings.HasPrefix(mimeType, `image/`) { + return this.renderImageFile(absPath, mimeType) + + } else if strings.HasPrefix(mimeType, `video/`) { + return this.renderScaledFfmpeg(absPath) + + } else { + return nil, ErrUnsupportedFiletype + + } +} diff --git a/Thumbnailer.go b/Thumbnailer.go index 1dedae7..0643023 100644 --- a/Thumbnailer.go +++ b/Thumbnailer.go @@ -2,38 +2,7 @@ package thumbnail import ( "errors" - "image" - "image/gif" - "image/jpeg" - "image/png" - "io" - "mime" - "os" - "path/filepath" "strings" - - lru "github.com/hashicorp/golang-lru" - "golang.org/x/image/bmp" - "golang.org/x/image/webp" -) - -type OutputFormat uint8 -type AspectFormat uint8 -type ScaleFormat uint8 - -const ( - OUTPUT_PNG_CRUSH OutputFormat = 2 - OUTPUT_JPG OutputFormat = 3 - OUTPUT__DEFAULT = OUTPUT_PNG_CRUSH - - ASPECT_PAD_TO_DIMENSIONS AspectFormat = 80 - ASPECT_RESPECT_MAX_DIMENSION_ONLY AspectFormat = 81 - ASPECT_CROP_TO_DIMENSIONS AspectFormat = 82 - ASPECT__DEFAULT = ASPECT_PAD_TO_DIMENSIONS - - SCALEFMT_NN ScaleFormat = 120 - SCALEFMT_BILINEAR ScaleFormat = 121 - SCALEFMT__DEFAULT = SCALEFMT_BILINEAR ) var ( @@ -41,70 +10,8 @@ var ( ErrUnsupportedFiletype error = errors.New("Unsupported filetype") ) -type Thumbnailer struct { - width int - height int - ofmt OutputFormat - afmt AspectFormat - sfmt ScaleFormat - - thumbCache *lru.Cache // threadsafe, might be nil -} - -func NewThumbnailer(Width, Height int, MaxCacheSize uint) *Thumbnailer { - return NewThumbnailerEx(Width, Height, MaxCacheSize, OUTPUT__DEFAULT, ASPECT__DEFAULT, SCALEFMT__DEFAULT) -} - -func NewThumbnailerEx(Width, Height int, MaxCacheSize uint, of OutputFormat, af AspectFormat, sf ScaleFormat) *Thumbnailer { - - ret := &Thumbnailer{ - width: Width, - height: Height, - ofmt: of, - afmt: af, - sfmt: sf, - thumbCache: nil, - } - - if MaxCacheSize > 0 { - thumbCache, err := lru.New(int(MaxCacheSize)) - if err != nil { - panic(err) - } - ret.thumbCache = thumbCache - } - - return ret -} - -func (this *Thumbnailer) Width() int { - return this.width -} - -func (this *Thumbnailer) Height() int { - return this.height -} - -func (this *Thumbnailer) RenderFile(absPath string) ([]byte, error) { - - if this.thumbCache != nil { - thumb, ok := this.thumbCache.Get(absPath) - if ok { - return thumb.([]byte), nil - } - } - - // Add to cache - thumb, err := this.RenderFile_NoCache(absPath) - if err != nil { - return nil, err - } - - if this.thumbCache != nil { - this.thumbCache.Add(absPath, thumb) - } - - return thumb, nil +type Thumbnailer interface { + RenderFile(absPath string) ([]byte, error) } func FiletypeSupported(ext string) bool { @@ -118,42 +25,3 @@ func FiletypeSupported(ext string) bool { return false } } - -func (this *Thumbnailer) RenderFile_NoCache(absPath string) ([]byte, error) { - return this.RenderFile_NoCache_MimeType(absPath, mime.TypeByExtension(filepath.Ext(absPath))) -} - -func (this *Thumbnailer) RenderFile_NoCache_MimeType(absPath, mimeType string) ([]byte, error) { - - fh, err := os.OpenFile(absPath, os.O_RDONLY, 0400) - if err != nil { - return nil, err - } - defer fh.Close() - - type imageDecoder func(io.Reader) (image.Image, error) - imageDecoders := map[string]imageDecoder{ - `image/jpeg`: jpeg.Decode, - `image/png`: png.Decode, - `image/gif`: gif.Decode, - `image/webp`: webp.Decode, - `image/bmp`: bmp.Decode, - } - - if fn, ok := imageDecoders[mimeType]; ok { - src, err := fn(fh) - if err != nil { - return nil, err - } - - return this.RenderScaledImage(src) - - } else if strings.HasPrefix(mimeType, `video/`) { - return this.RenderScaledFfmpeg(absPath) - - } else { - return nil, ErrUnsupportedFiletype - - } - -} diff --git a/config.go b/config.go new file mode 100644 index 0000000..15ba73e --- /dev/null +++ b/config.go @@ -0,0 +1,37 @@ +package thumbnail + +type OutputFormat uint8 +type AspectFormat uint8 +type ScaleFormat uint8 + +const ( + OUTPUT_PNG_CRUSH OutputFormat = 2 + OUTPUT_JPG OutputFormat = 3 + OUTPUT_WEBP OutputFormat = 4 + OUTPUT__DEFAULT = OUTPUT_PNG_CRUSH + + ASPECT_PAD_TO_DIMENSIONS AspectFormat = 80 + ASPECT_RESPECT_MAX_DIMENSION_ONLY AspectFormat = 81 + ASPECT_CROP_TO_DIMENSIONS AspectFormat = 82 + ASPECT__DEFAULT = ASPECT_PAD_TO_DIMENSIONS + + SCALEFMT_NN ScaleFormat = 120 + SCALEFMT_BILINEAR ScaleFormat = 121 + SCALEFMT__DEFAULT = SCALEFMT_BILINEAR +) + +type Config struct { + Width int + Height int + Output OutputFormat + Aspect AspectFormat + Scale ScaleFormat +} + +var DefaultConfig = Config{ + Width: 128, + Height: 128, + Output: OUTPUT__DEFAULT, + Aspect: ASPECT__DEFAULT, + Scale: SCALEFMT__DEFAULT, +} diff --git a/doc.go b/doc.go deleted file mode 100644 index 9a259ce..0000000 --- a/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package thumbnail contains functions for taking the thumbnail of image and -// video files, and caching the result for performance. -package thumbnail diff --git a/image.go b/image.go index 33238cc..cc55f09 100644 --- a/image.go +++ b/image.go @@ -3,32 +3,70 @@ package thumbnail import ( "bytes" "image" + "image/gif" "image/jpeg" + "image/png" + "io" + "os" + + "golang.org/x/image/bmp" + "golang.org/x/image/webp" ) -func (this *Thumbnailer) RenderScaledImage(src image.Image) ([]byte, error) { +type imageDecoder func(io.Reader) (image.Image, error) + +var imageDecoders map[string]imageDecoder = map[string]imageDecoder{ + `image/jpeg`: jpeg.Decode, + `image/png`: png.Decode, + `image/gif`: gif.Decode, + `image/webp`: webp.Decode, + `image/bmp`: bmp.Decode, +} + +func (this *DirectThumbnailer) renderImageFile(absPath, mimeType string) ([]byte, error) { + + fh, err := os.OpenFile(absPath, os.O_RDONLY, 0400) + if err != nil { + return nil, err + } + defer fh.Close() + + fn, ok := imageDecoders[mimeType] + if !ok { + return nil, ErrUnsupportedFiletype + } + + src, err := fn(fh) + if err != nil { + return nil, err + } + + return this.RenderScaledImage(src) +} + +func (this *DirectThumbnailer) RenderScaledImage(src image.Image) ([]byte, error) { srcW := src.Bounds().Max.X srcH := src.Bounds().Max.Y destW := 0 destH := 0 if srcW > srcH { - destW = this.width - destH = this.height * srcH / srcW + destW = this.cfg.Width + destH = this.cfg.Height * srcH / srcW } else { - destW = this.width * srcW / srcH - destH = this.height + destW = this.cfg.Width * srcW / srcH + destH = this.cfg.Height } - offsetX := (this.width - destW) / 2 - offsetY := (this.height - destH) / 2 + offsetX := (this.cfg.Width - destW) / 2 + offsetY := (this.cfg.Height - destH) / 2 scaleW := float64(srcW) / float64(destW) scaleH := float64(srcH) / float64(destH) - dest := image.NewRGBA(image.Rectangle{Max: image.Point{X: this.width, Y: this.height}}) + dest := image.NewRGBA(image.Rectangle{Max: image.Point{X: this.cfg.Width, Y: this.cfg.Height}}) - switch this.sfmt { + switch this.cfg.Scale { case SCALEFMT_BILINEAR: @@ -56,7 +94,7 @@ func (this *Thumbnailer) RenderScaledImage(src image.Image) ([]byte, error) { } - switch this.ofmt { + switch this.cfg.Output { case OUTPUT_PNG_CRUSH: return crushFast(dest) diff --git a/video.go b/video.go index bf2be9b..269b952 100644 --- a/video.go +++ b/video.go @@ -7,18 +7,18 @@ import ( "os/exec" ) -func (this *Thumbnailer) RenderScaledFfmpeg(absPath string) ([]byte, error) { +func (this *DirectThumbnailer) renderScaledFfmpeg(absPath string) ([]byte, error) { scaleCmd := fmt.Sprintf( `thumbnail,scale='if(gt(a,%d/%d),%d,-1)':'if(gt(a,%d/%d),-1,%d)'`, - this.height, this.width, this.height, - this.height, this.width, this.width, + this.cfg.Height, this.cfg.Width, this.cfg.Height, + this.cfg.Height, this.cfg.Width, this.cfg.Width, ) // BUG(): always produces ASPECT_SVELTE shape - need to re-pad for ASPECT_PADDED var vcodec string - switch this.ofmt { + switch this.cfg.Output { case OUTPUT_JPG: vcodec = "mjpeg" // yes really case OUTPUT_PNG_CRUSH: