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