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 }