github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chat/attachments/preview.go (about)

     1  package attachments
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"image"
     8  	"image/color"
     9  	"image/color/palette"
    10  	"image/draw"
    11  	"image/gif"
    12  	"image/jpeg"
    13  	"image/png"
    14  	"io"
    15  	"strings"
    16  
    17  	"github.com/keybase/client/go/chat/types"
    18  	"github.com/keybase/client/go/chat/utils"
    19  
    20  	_ "github.com/keybase/golang-ico" // for image decoding
    21  	"github.com/nfnt/resize"
    22  	"golang.org/x/image/bmp"
    23  	_ "golang.org/x/image/bmp" // for image decoding
    24  	"golang.org/x/image/tiff"
    25  	"golang.org/x/net/context"
    26  
    27  	"camlistore.org/pkg/images"
    28  )
    29  
    30  const (
    31  	previewImageWidth  = 640
    32  	previewImageHeight = 640
    33  )
    34  
    35  type PreviewRes struct {
    36  	Source            []byte
    37  	ContentType       string
    38  	BaseWidth         int
    39  	BaseHeight        int
    40  	BaseDurationMs    int
    41  	PreviewWidth      int
    42  	PreviewHeight     int
    43  	PreviewDurationMs int
    44  }
    45  
    46  func IsFatalImageErr(err error) bool {
    47  	switch err {
    48  	case image.ErrFormat,
    49  		bmp.ErrUnsupported:
    50  		return true
    51  	}
    52  	switch err.(type) {
    53  	case png.FormatError,
    54  		png.UnsupportedError,
    55  		tiff.FormatError,
    56  		tiff.UnsupportedError,
    57  		jpeg.FormatError,
    58  		jpeg.UnsupportedError:
    59  		return true
    60  	}
    61  	return false
    62  }
    63  
    64  // Preview creates preview assets from src.  It returns an in-memory BufferSource
    65  // and the content type of the preview asset.
    66  func Preview(ctx context.Context, log utils.DebugLabeler, src ReadResetter, contentType,
    67  	basename string, nvh types.NativeVideoHelper) (res *PreviewRes, err error) {
    68  	defer func() {
    69  		if IsFatalImageErr(err) {
    70  			log.Debug(ctx, "squashing %v", err)
    71  			err = nil
    72  			res = nil
    73  		}
    74  	}()
    75  	switch contentType {
    76  	case "image/jpeg", "image/png", "image/vnd.microsoft.icon", "image/x-icon":
    77  		return previewImage(ctx, log, src, basename, contentType)
    78  	case "image/gif":
    79  		return previewGIF(ctx, log, src, basename)
    80  	}
    81  	if strings.HasPrefix(contentType, "video") {
    82  		pre, err := previewVideo(ctx, log, src, basename, nvh)
    83  		if err == nil {
    84  			log.Debug(ctx, "Preview: found video preview for filename: %s contentType: %s", basename,
    85  				contentType)
    86  			return pre, nil
    87  		}
    88  		log.Debug(ctx, "Preview: failed to get video preview for filename: %s contentType: %s err: %s",
    89  			basename, contentType, err)
    90  		return previewVideoBlank(ctx, log, src, basename)
    91  	}
    92  	return nil, nil
    93  }
    94  
    95  // previewVideoBlank previews a video by inserting a black rectangle with a play button on it.
    96  func previewVideoBlank(ctx context.Context, log utils.DebugLabeler, src io.Reader,
    97  	basename string) (res *PreviewRes, err error) {
    98  	const width, height = 300, 150
    99  	img := image.NewNRGBA(image.Rect(0, 0, width, height))
   100  	for y := 0; y < height; y++ {
   101  		for x := 0; x < width; x++ {
   102  			img.Set(x, y, color.NRGBA{
   103  				R: 0,
   104  				G: 0,
   105  				B: 0,
   106  				A: 255,
   107  			})
   108  		}
   109  	}
   110  	var out bytes.Buffer
   111  	if err := png.Encode(&out, img); err != nil {
   112  		return res, err
   113  	}
   114  	imagePreview, err := previewImage(ctx, log, &out, basename, "image/png")
   115  	if err != nil {
   116  		return res, err
   117  	}
   118  	return &PreviewRes{
   119  		Source:         imagePreview.Source,
   120  		ContentType:    "image/png",
   121  		BaseWidth:      imagePreview.BaseWidth,
   122  		BaseHeight:     imagePreview.BaseHeight,
   123  		BaseDurationMs: 1,
   124  		PreviewHeight:  imagePreview.PreviewHeight,
   125  		PreviewWidth:   imagePreview.PreviewWidth,
   126  	}, nil
   127  }
   128  
   129  // previewImage will resize a single-frame image.
   130  func previewImage(ctx context.Context, log utils.DebugLabeler, src io.Reader, basename, contentType string) (res *PreviewRes, err error) {
   131  	defer func() {
   132  		// decoding ico images can cause a panic, let's catch anything here.
   133  		// https://github.com/biessek/golang-ico/issues/4
   134  		if r := recover(); r != nil {
   135  			log.Debug(ctx, "Recovered %v", r)
   136  			res = nil
   137  			err = fmt.Errorf("unable to preview image: %v", r)
   138  		}
   139  	}()
   140  	defer log.Trace(ctx, &err, "previewImage")()
   141  	// images.Decode in camlistore correctly handles exif orientation information.
   142  	log.Debug(ctx, "previewImage: decoding image")
   143  	img, _, err := images.Decode(src, nil)
   144  	if err != nil {
   145  		return nil, err
   146  	}
   147  
   148  	width, height := previewDimensions(img.Bounds())
   149  
   150  	log.Debug(ctx, "previewImage: resizing image: bounds: %s", img.Bounds())
   151  	preview := resize.Resize(width, height, img, resize.Bicubic)
   152  	var buf bytes.Buffer
   153  
   154  	var encodeContentType string
   155  	switch contentType {
   156  	case "image/vnd.microsoft.icon", "image/x-icon", "image/png":
   157  		encodeContentType = "image/png"
   158  		if err := png.Encode(&buf, preview); err != nil {
   159  			return nil, err
   160  		}
   161  	default:
   162  		encodeContentType = "image/jpeg"
   163  		if err := jpeg.Encode(&buf, preview, &jpeg.Options{Quality: 90}); err != nil {
   164  			return nil, err
   165  		}
   166  	}
   167  
   168  	return &PreviewRes{
   169  		Source:        buf.Bytes(),
   170  		ContentType:   encodeContentType,
   171  		BaseWidth:     img.Bounds().Dx(),
   172  		BaseHeight:    img.Bounds().Dy(),
   173  		PreviewWidth:  int(width),
   174  		PreviewHeight: int(height),
   175  	}, nil
   176  }
   177  
   178  // previewGIF handles resizing multiple frames in an animated gif.
   179  // Based on code in https://github.com/dpup/go-scratch/blob/master/gif-resize/gif-resize.go
   180  func previewGIF(ctx context.Context, log utils.DebugLabeler, src io.Reader, basename string) (*PreviewRes, error) {
   181  	raw, err := io.ReadAll(src)
   182  	if err != nil {
   183  		return nil, err
   184  	}
   185  	g, err := gif.DecodeAll(bytes.NewReader(raw))
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	frames := len(g.Image)
   191  	if frames == 0 {
   192  		return nil, errors.New("no image frames in GIF")
   193  	}
   194  
   195  	log.Debug(ctx, "previewGIF: number of frames = %d", frames)
   196  
   197  	var baseDuration int
   198  	if frames > 1 {
   199  		if len(raw) < 10*1024*1024 {
   200  			log.Debug(ctx, "previewGif: not resizing because multiple-frame original < 10MB")
   201  
   202  			// don't resize if multiple frames and < 5MB
   203  			bounds := g.Image[0].Bounds()
   204  			duration := gifDuration(g)
   205  			res := &PreviewRes{
   206  				Source:            raw,
   207  				ContentType:       "image/gif",
   208  				BaseWidth:         bounds.Dx(),
   209  				BaseHeight:        bounds.Dy(),
   210  				PreviewWidth:      bounds.Dx(),
   211  				PreviewHeight:     bounds.Dy(),
   212  				BaseDurationMs:    duration,
   213  				PreviewDurationMs: duration,
   214  			}
   215  			return res, nil
   216  		}
   217  
   218  		log.Debug(ctx, "previewGif: large multiple-frame gif: %d, just using frame 0", len(raw))
   219  		baseDuration = gifDuration(g)
   220  		g.Image = g.Image[:1]
   221  		g.Delay = g.Delay[:1]
   222  		g.Disposal = g.Disposal[:1]
   223  	}
   224  
   225  	// create a new image based on the first frame to draw
   226  	// the incremental frames
   227  	origBounds := g.Image[0].Bounds()
   228  	img := image.NewRGBA(origBounds)
   229  
   230  	// draw each frame, then resize it, replacing the existing frames.
   231  	width, height := previewDimensions(origBounds)
   232  	log.Debug(ctx, "previewGif: resizing to %d x %d", width, height)
   233  	for index, frame := range g.Image {
   234  		bounds := frame.Bounds()
   235  		draw.Draw(img, bounds, frame, bounds.Min, draw.Over)
   236  		g.Image[index] = imageToPaletted(resize.Resize(width, height, img, resize.Bicubic))
   237  		log.Debug(ctx, "previewGIF: resized frame %d", index)
   238  	}
   239  
   240  	// change the image Config to the new size
   241  	g.Config.Width = int(width)
   242  	g.Config.Height = int(height)
   243  
   244  	// encode all the frames into buf
   245  	var buf bytes.Buffer
   246  	if err := gif.EncodeAll(&buf, g); err != nil {
   247  		return nil, err
   248  	}
   249  
   250  	res := &PreviewRes{
   251  		Source:         buf.Bytes(),
   252  		ContentType:    "image/gif",
   253  		BaseWidth:      origBounds.Dx(),
   254  		BaseHeight:     origBounds.Dy(),
   255  		PreviewWidth:   int(width),
   256  		PreviewHeight:  int(height),
   257  		BaseDurationMs: baseDuration,
   258  	}
   259  
   260  	if len(g.Image) > 1 {
   261  		res.PreviewDurationMs = gifDuration(g)
   262  	}
   263  
   264  	return res, nil
   265  }
   266  
   267  func previewDimensions(origBounds image.Rectangle) (uint, uint) {
   268  	origWidth := uint(origBounds.Dx())
   269  	origHeight := uint(origBounds.Dy())
   270  
   271  	if previewImageWidth >= origWidth && previewImageHeight >= origHeight {
   272  		return origWidth, origHeight
   273  	}
   274  
   275  	newWidth, newHeight := origWidth, origHeight
   276  	// Preserve aspect ratio
   277  	if origWidth > previewImageWidth {
   278  		newHeight = origHeight * previewImageWidth / origWidth
   279  		if newHeight < 1 {
   280  			newHeight = 1
   281  		}
   282  		newWidth = previewImageWidth
   283  	}
   284  
   285  	if newHeight > previewImageHeight {
   286  		newWidth = newWidth * previewImageHeight / newHeight
   287  		if newWidth < 1 {
   288  			newWidth = 1
   289  		}
   290  		newHeight = previewImageHeight
   291  	}
   292  
   293  	return newWidth, newHeight
   294  }
   295  
   296  // imageToPaletted converts image.Image to *image.Paletted.
   297  // From https://github.com/dpup/go-scratch/blob/master/gif-resize/gif-resize.go
   298  func imageToPaletted(img image.Image) *image.Paletted {
   299  	b := img.Bounds()
   300  	pm := image.NewPaletted(b, palette.Plan9)
   301  	draw.FloydSteinberg.Draw(pm, b, img, image.Point{})
   302  	return pm
   303  }
   304  
   305  // gifDuration returns the duration of one loop of an animated gif
   306  // in milliseconds.
   307  func gifDuration(g *gif.GIF) int {
   308  	var total int
   309  	for _, d := range g.Delay {
   310  		total += d
   311  	}
   312  
   313  	// total is in 100ths of a second, multiply by 10 to get milliseconds
   314  	return total * 10
   315  }