diff --git a/DirectThumbnailer.go b/DirectThumbnailer.go index 2d1e516..97d32c9 100644 --- a/DirectThumbnailer.go +++ b/DirectThumbnailer.go @@ -1,6 +1,7 @@ package thumbnail import ( + "image" "mime" "path/filepath" "strings" @@ -29,14 +30,29 @@ func NewThumbnailerEx(opts *Config) Thumbnailer { func (this *DirectThumbnailer) RenderFile(absPath string) ([]byte, error) { mimeType := mime.TypeByExtension(filepath.Ext(absPath)) + // Decode source + + var src image.Image + var err error = nil if strings.HasPrefix(mimeType, `image/`) { - return this.renderImageFile(absPath, mimeType) + src, err = this.imageSnapshot(absPath, mimeType) } else if strings.HasPrefix(mimeType, `video/`) { - return this.renderScaledFfmpeg(absPath) + src, err = this.videoSnapshot(absPath) } else { return nil, ErrUnsupportedFiletype } + if err != nil { + return nil, err + } + + // Scale + + dest := this.scaleImage(src) + + // Rasterise result + + return this.encode(dest) } diff --git a/config.go b/config.go index 15ba73e..cd23c27 100644 --- a/config.go +++ b/config.go @@ -5,19 +5,16 @@ 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 + Png OutputFormat = 1 + PngCrush OutputFormat = 2 + Jpeg OutputFormat = 3 + Bmp OutputFormat = 4 - 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 + FitOutside AspectFormat = 80 // Pad out with black bars to dimensions + FitInside AspectFormat = 82 // Crop to dimensions - SCALEFMT_NN ScaleFormat = 120 - SCALEFMT_BILINEAR ScaleFormat = 121 - SCALEFMT__DEFAULT = SCALEFMT_BILINEAR + NearestNeighbour ScaleFormat = 120 + Bilinear ScaleFormat = 121 ) type Config struct { @@ -31,7 +28,7 @@ type Config struct { var DefaultConfig = Config{ Width: 128, Height: 128, - Output: OUTPUT__DEFAULT, - Aspect: ASPECT__DEFAULT, - Scale: SCALEFMT__DEFAULT, + Output: Jpeg, + Aspect: FitInside, + Scale: Bilinear, } diff --git a/decodeImage.go b/decodeImage.go new file mode 100644 index 0000000..79ee136 --- /dev/null +++ b/decodeImage.go @@ -0,0 +1,43 @@ +package thumbnail + +import ( + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "os" + + "golang.org/x/image/bmp" + "golang.org/x/image/webp" +) + +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) imageSnapshot(absPath, mimeType string) (image.Image, error) { + + // Load file from path + + fh, err := os.OpenFile(absPath, os.O_RDONLY, 0400) + if err != nil { + return nil, err + } + defer fh.Close() + + // Decode image + + fn, ok := imageDecoders[mimeType] + if !ok { + return nil, ErrUnsupportedFiletype + } + + return fn(fh) +} diff --git a/decodeVideo.go b/decodeVideo.go new file mode 100644 index 0000000..3c6240f --- /dev/null +++ b/decodeVideo.go @@ -0,0 +1,52 @@ +package thumbnail + +import ( + "bytes" + "image" + "image/png" + "io" + "os/exec" +) + +func (this *DirectThumbnailer) videoSnapshot(absPath string) (image.Image, error) { + + cmd := exec.Command( + "ffmpeg", + "-loglevel", "0", + "-timelimit", "10", // seconds + "-an", + "-i", absPath, + "-vf", `thumbnail`, + "-frames:v", "1", + "-f", "image2pipe", + "-c:v", "png", // always PNG output - we will resample/rescale it ourselves + "-", + ) + + // -ss 00:00:30 + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + err = cmd.Start() + if err != nil { + return nil, err + } + + out := bytes.Buffer{} + _, err = io.Copy(&out, stdout) + if err != nil { + return nil, err + } + + err = cmd.Wait() + if err != nil { + return nil, err + } + + // Try to decode as PNG image + + return png.Decode(bytes.NewReader(out.Bytes())) +} diff --git a/image.go b/scaler.go similarity index 64% rename from image.go rename to scaler.go index cc55f09..98b6afb 100644 --- a/image.go +++ b/scaler.go @@ -3,48 +3,13 @@ package thumbnail import ( "bytes" "image" - "image/gif" "image/jpeg" "image/png" - "io" - "os" "golang.org/x/image/bmp" - "golang.org/x/image/webp" ) -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) { +func (this *DirectThumbnailer) scaleImage(src image.Image) image.Image { srcW := src.Bounds().Max.X srcH := src.Bounds().Max.Y destW := 0 @@ -68,7 +33,7 @@ func (this *DirectThumbnailer) RenderScaledImage(src image.Image) ([]byte, error switch this.cfg.Scale { - case SCALEFMT_BILINEAR: + case Bilinear: for y := 0; y < destH; y += 1 { for x := 0; x < destW; x += 1 { @@ -82,7 +47,7 @@ func (this *DirectThumbnailer) RenderScaledImage(src image.Image) ([]byte, error } } - case SCALEFMT_NN: + case NearestNeighbour: for y := 0; y < destH; y += 1 { for x := 0; x < destW; x += 1 { @@ -94,11 +59,24 @@ func (this *DirectThumbnailer) RenderScaledImage(src image.Image) ([]byte, error } + return dest +} + +func (this *DirectThumbnailer) encode(dest image.Image) ([]byte, error) { + switch this.cfg.Output { - case OUTPUT_PNG_CRUSH: + case Png: + buff := bytes.Buffer{} + err := png.Encode(&buff, dest) + if err != nil { + return nil, err + } + return buff.Bytes(), nil + + case PngCrush: return crushFast(dest) - case OUTPUT_JPG: + case Jpeg: buff := bytes.Buffer{} err := jpeg.Encode(&buff, dest, &jpeg.Options{Quality: jpeg.DefaultQuality}) if err != nil { @@ -107,6 +85,14 @@ func (this *DirectThumbnailer) RenderScaledImage(src image.Image) ([]byte, error return buff.Bytes(), nil + case Bmp: + buff := bytes.Buffer{} + err := bmp.Encode(&buff, dest) + if err != nil { + return nil, err + } + return buff.Bytes(), nil + default: return nil, ErrInvalidOption diff --git a/video.go b/video.go deleted file mode 100644 index 269b952..0000000 --- a/video.go +++ /dev/null @@ -1,67 +0,0 @@ -package thumbnail - -import ( - "bytes" - "fmt" - "io" - "os/exec" -) - -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.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.cfg.Output { - case OUTPUT_JPG: - vcodec = "mjpeg" // yes really - case OUTPUT_PNG_CRUSH: - vcodec = "png" - default: - return nil, ErrInvalidOption - } - - cmd := exec.Command( - "ffmpeg", - "-loglevel", "0", - "-timelimit", "10", // seconds - "-an", - "-i", absPath, - "-vf", scaleCmd, - "-frames:v", "1", - "-f", "image2pipe", - "-c:v", vcodec, - "-", - ) - - // -ss 00:00:30 - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - err = cmd.Start() - if err != nil { - return nil, err - } - - out := bytes.Buffer{} - _, err = io.Copy(&out, stdout) - if err != nil { - return nil, err - } - - err = cmd.Wait() - if err != nil { - return nil, err - } - - return out.Bytes(), nil -}