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 }