1/* 2Copyright 2012 The Perkeep Authors 3 4Licensed under the Apache License, Version 2.0 (the "License"); 5you may not use this file except in compliance with the License. 6You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10Unless required by applicable law or agreed to in writing, software 11distributed under the License is distributed on an "AS IS" BASIS, 12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13See the License for the specific language governing permissions and 14limitations under the License. 15*/ 16 17package images // import "perkeep.org/internal/images" 18 19import ( 20 "bytes" 21 "errors" 22 "fmt" 23 "image" 24 "image/draw" 25 "image/jpeg" 26 "io" 27 "io/ioutil" 28 "log" 29 "os" 30 "os/exec" 31 "path/filepath" 32 "strconv" 33 "strings" 34 "sync" 35 "time" 36 37 _ "image/gif" 38 _ "image/png" 39 40 "perkeep.org/internal/images/fastjpeg" 41 "perkeep.org/internal/images/resize" 42 43 "github.com/nf/cr2" 44 "github.com/rwcarlsen/goexif/exif" 45 "go4.org/media/heif" 46 "go4.org/readerutil" 47 "go4.org/syncutil" 48 49 // TODO(mpl, wathiede): add test(s) to check we can decode both tiff and cr2, 50 // so we don't mess up the import order again. 51 // See https://camlistore-review.googlesource.com/5196 comments. 52 53 // tiff package must be imported after any image packages that decode 54 // tiff-like formats, i.e. CR2 or DNG 55 _ "golang.org/x/image/tiff" 56) 57 58var ErrHEIC = errors.New("HEIC decoding not implemented yet") 59 60func init() { 61 image.RegisterFormat("heic", 62 "????ftypheic", 63 func(io.Reader) (image.Image, error) { 64 return nil, ErrHEIC 65 }, 66 func(r io.Reader) (image.Config, error) { 67 return decodeHEIFConfig(readerutil.NewBufferingReaderAt(io.LimitReader(r, 8<<20))) 68 }) 69} 70 71var disableThumbCache, _ = strconv.ParseBool(os.Getenv("CAMLI_DISABLE_THUMB_CACHE")) 72 73// thumbnailVersion should be incremented whenever we want to 74// invalidate the cache of previous thumbnails on the server's 75// cache and in browsers. 76const thumbnailVersion = "2" 77 78// ThumbnailVersion returns a string safe for URL query components 79// which is a generation number. Whenever the thumbnailing code is 80// updated, so will this string. It should be placed in some URL 81// component (typically "tv"). 82func ThumbnailVersion() string { 83 if disableThumbCache { 84 return fmt.Sprintf("nocache%d", time.Now().UnixNano()) 85 } 86 return thumbnailVersion 87} 88 89// Exif Orientation Tag values 90// http://sylvana.net/jpegcrop/exif_orientation.html 91const ( 92 topLeftSide = 1 93 topRightSide = 2 94 bottomRightSide = 3 95 bottomLeftSide = 4 96 leftSideTop = 5 97 rightSideTop = 6 98 rightSideBottom = 7 99 leftSideBottom = 8 100) 101 102// The FlipDirection type is used by the Flip option in DecodeOpts 103// to indicate in which direction to flip an image. 104type FlipDirection int 105 106// FlipVertical and FlipHorizontal are two possible FlipDirections 107// values to indicate in which direction an image will be flipped. 108const ( 109 FlipVertical FlipDirection = 1 << iota 110 FlipHorizontal 111) 112 113type DecodeOpts struct { 114 // Rotate specifies how to rotate the image. 115 // If nil, the image is rotated automatically based on EXIF metadata. 116 // If an int, Rotate is the number of degrees to rotate 117 // counter clockwise and must be one of 0, 90, -90, 180, or 118 // -180. 119 Rotate interface{} 120 121 // Flip specifies how to flip the image. 122 // If nil, the image is flipped automatically based on EXIF metadata. 123 // Otherwise, Flip is a FlipDirection bitfield indicating how to flip. 124 Flip interface{} 125 126 // MaxWidgth and MaxHeight optionally specify bounds on the 127 // image's size. Rescaling is done before flipping or rotating. 128 // Proportions are conserved, so the smallest of the two is used 129 // as the decisive one if needed. 130 MaxWidth, MaxHeight int 131 132 // ScaleWidth and ScaleHeight optionally specify how to rescale the 133 // image's dimensions. Rescaling is done before flipping or rotating. 134 // Proportions are conserved, so the smallest of the two is used 135 // as the decisive one if needed. 136 // They overrule MaxWidth and MaxHeight. 137 ScaleWidth, ScaleHeight float32 138 139 // TODO: consider alternate options if scaled ratio doesn't 140 // match original ratio: 141 // Crop bool 142 // Stretch bool 143} 144 145// Config is like the standard library's image.Config as used by DecodeConfig. 146type Config struct { 147 Width, Height int 148 Format string 149 Modified bool // true if Decode actually rotated or flipped the image. 150 HEICEXIF []byte // if not nil, the part of the HEIC file that contains EXIF metadata 151} 152 153func (c *Config) setBounds(im image.Image) { 154 if im != nil { 155 c.Width = im.Bounds().Dx() 156 c.Height = im.Bounds().Dy() 157 } 158} 159 160func rotate(im image.Image, angle int) image.Image { 161 var rotated *image.NRGBA 162 // trigonometric (i.e counter clock-wise) 163 switch angle { 164 case 90: 165 newH, newW := im.Bounds().Dx(), im.Bounds().Dy() 166 rotated = image.NewNRGBA(image.Rect(0, 0, newW, newH)) 167 for y := 0; y < newH; y++ { 168 for x := 0; x < newW; x++ { 169 rotated.Set(x, y, im.At(newH-1-y, x)) 170 } 171 } 172 case -90: 173 newH, newW := im.Bounds().Dx(), im.Bounds().Dy() 174 rotated = image.NewNRGBA(image.Rect(0, 0, newW, newH)) 175 for y := 0; y < newH; y++ { 176 for x := 0; x < newW; x++ { 177 rotated.Set(x, y, im.At(y, newW-1-x)) 178 } 179 } 180 case 180, -180: 181 newW, newH := im.Bounds().Dx(), im.Bounds().Dy() 182 rotated = image.NewNRGBA(image.Rect(0, 0, newW, newH)) 183 for y := 0; y < newH; y++ { 184 for x := 0; x < newW; x++ { 185 rotated.Set(x, y, im.At(newW-1-x, newH-1-y)) 186 } 187 } 188 default: 189 return im 190 } 191 return rotated 192} 193 194// flip returns a flipped version of the image im, according to 195// the direction(s) in dir. 196// It may flip the input im in place and return it, or it may allocate a 197// new NRGBA (if im is an *image.YCbCr). 198func flip(im image.Image, dir FlipDirection) image.Image { 199 if dir == 0 { 200 return im 201 } 202 ycbcr := false 203 var nrgba image.Image 204 dx, dy := im.Bounds().Dx(), im.Bounds().Dy() 205 di, ok := im.(draw.Image) 206 if !ok { 207 if _, ok := im.(*image.YCbCr); !ok { 208 log.Printf("failed to flip image: input does not satisfy draw.Image") 209 return im 210 } 211 // because YCbCr does not implement Set, we replace it with a new NRGBA 212 ycbcr = true 213 nrgba = image.NewNRGBA(image.Rect(0, 0, dx, dy)) 214 di, ok = nrgba.(draw.Image) 215 if !ok { 216 log.Print("failed to flip image: could not cast an NRGBA to a draw.Image") 217 return im 218 } 219 } 220 if dir&FlipHorizontal != 0 { 221 for y := 0; y < dy; y++ { 222 for x := 0; x < dx/2; x++ { 223 old := im.At(x, y) 224 di.Set(x, y, im.At(dx-1-x, y)) 225 di.Set(dx-1-x, y, old) 226 } 227 } 228 } 229 if dir&FlipVertical != 0 { 230 for y := 0; y < dy/2; y++ { 231 for x := 0; x < dx; x++ { 232 old := im.At(x, y) 233 di.Set(x, y, im.At(x, dy-1-y)) 234 di.Set(x, dy-1-y, old) 235 } 236 } 237 } 238 if ycbcr { 239 return nrgba 240 } 241 return im 242} 243 244// ScaledDimensions returns the newWidth and newHeight obtained 245// when an image of dimensions w x h has to be rescaled under 246// mw x mh, while conserving the proportions. 247// It returns 1,1 if any of the parameter is 0. 248func ScaledDimensions(w, h, mw, mh int) (newWidth int, newHeight int) { 249 if w == 0 || h == 0 || mw == 0 || mh == 0 { 250 imageDebug("ScaledDimensions was given as 0; returning 1x1 as dimensions.") 251 return 1, 1 252 } 253 newWidth, newHeight = mw, mh 254 if float32(h)/float32(mh) > float32(w)/float32(mw) { 255 newWidth = w * mh / h 256 } else { 257 newHeight = h * mw / w 258 } 259 return 260} 261 262// rescaleDimensions computes the width & height in the pre-rotated 263// orientation needed to meet the post-rotation constraints of opts. 264// The image bound by b represents the pre-rotated dimensions of the image. 265// needRescale is true if the image requires a resize. 266func (opts *DecodeOpts) rescaleDimensions(b image.Rectangle, swapDimensions bool) (width, height int, needRescale bool) { 267 w, h := b.Dx(), b.Dy() 268 mw, mh := opts.MaxWidth, opts.MaxHeight 269 mwf, mhf := opts.ScaleWidth, opts.ScaleHeight 270 if mw == 0 && mh == 0 && mwf == 0 && mhf == 0 { 271 return w, h, false 272 } 273 274 // Floating point compares probably only allow this to work if the values 275 // were specified as the literal 1 or 1.0, computed values will likely be 276 // off. If Scale{Width,Height} end up being 1.0-epsilon we'll rescale 277 // when it probably wouldn't even be noticeable but that's okay. 278 if opts.ScaleWidth == 1.0 && opts.ScaleHeight == 1.0 { 279 return w, h, false 280 } 281 282 if swapDimensions { 283 w, h = h, w 284 } 285 286 // ScaleWidth and ScaleHeight overrule MaxWidth and MaxHeight 287 if mwf > 0.0 && mwf <= 1 { 288 mw = int(mwf * float32(w)) 289 } 290 if mhf > 0.0 && mhf <= 1 { 291 mh = int(mhf * float32(h)) 292 } 293 294 neww, newh := ScaledDimensions(w, h, mw, mh) 295 if neww > w || newh > h { 296 // Don't scale up. 297 return w, h, false 298 } 299 300 needRescale = neww != w || newh != h 301 if swapDimensions { 302 return newh, neww, needRescale 303 } 304 return neww, newh, needRescale 305} 306 307// rescale resizes im in-place to the dimensions sw x sh, overwriting the 308// existing pixel data. It is up to the caller to ensure sw & sh maintain the 309// aspect ratio of im. 310func rescale(im image.Image, sw, sh int) image.Image { 311 b := im.Bounds() 312 w, h := b.Dx(), b.Dy() 313 if sw == w && sh == h { 314 return im 315 } 316 317 // If it's gigantic, it's more efficient to downsample first 318 // and then resize; resizing will smooth out the roughness. 319 // (trusting the moustachio guys on that one). 320 if w > sw*2 && h > sh*2 { 321 im = resize.ResampleInplace(im, b, sw*2, sh*2) 322 return resize.HalveInplace(im) 323 } 324 return resize.Resize(im, b, sw, sh) 325} 326 327// forcedRotate checks if the values in opts explicitly set a rotation. 328func (opts *DecodeOpts) forcedRotate() bool { 329 return opts != nil && opts.Rotate != nil 330} 331 332// forcedRotate checks if the values in opts explicitly set a flip. 333func (opts *DecodeOpts) forcedFlip() bool { 334 return opts != nil && opts.Flip != nil 335} 336 337// useEXIF checks if the values in opts imply EXIF data should be used for 338// orientation. 339func (opts *DecodeOpts) useEXIF() bool { 340 return !(opts.forcedRotate() || opts.forcedFlip()) 341} 342 343// forcedOrientation returns the rotation and flip values stored in opts. The 344// values are asserted to their proper type, and err is non-nil if an invalid 345// value is found. This function ignores the orientation stored in EXIF. 346// If auto-correction of the image's orientation is desired, it is the 347// caller's responsibility to check via useEXIF first. 348func (opts *DecodeOpts) forcedOrientation() (angle int, flipMode FlipDirection, err error) { 349 var ( 350 ok bool 351 ) 352 if opts.forcedRotate() { 353 if angle, ok = opts.Rotate.(int); !ok { 354 return 0, 0, fmt.Errorf("Rotate should be an int, not a %T", opts.Rotate) 355 } 356 } 357 if opts.forcedFlip() { 358 if flipMode, ok = opts.Flip.(FlipDirection); !ok { 359 return 0, 0, fmt.Errorf("Flip should be a FlipDirection, not a %T", opts.Flip) 360 } 361 } 362 return angle, flipMode, nil 363} 364 365var debug, _ = strconv.ParseBool(os.Getenv("CAMLI_DEBUG_IMAGES")) 366 367func imageDebug(msg string) { 368 if debug { 369 log.Print("internal/images: " + msg) 370 } 371} 372 373func decodeHEIFConfig(rat io.ReaderAt) (image.Config, error) { 374 var c image.Config 375 hf := heif.Open(rat) 376 it, err := hf.PrimaryItem() 377 if err != nil { 378 return c, err 379 } 380 w, h, ok := it.SpatialExtents() 381 if !ok { 382 return c, errors.New("no spacial extents found for primary item") 383 } 384 return image.Config{ 385 Width: w, 386 Height: h, 387 }, nil 388} 389 390// DecodeConfig returns the image Config similarly to 391// the standard library's image.DecodeConfig with the 392// addition that it also checks for an EXIF orientation, 393// and sets the Width and Height as they would visibly 394// be after correcting for that orientation. 395func DecodeConfig(r io.Reader) (Config, error) { 396 var buf bytes.Buffer 397 tr := io.TeeReader(io.LimitReader(r, 8<<20), &buf) 398 399 conf, format, err := image.DecodeConfig(tr) 400 if err != nil { 401 if debug { 402 log.Printf("internal/images: DecodeConfig failed after reading %d bytes: %v", buf.Len(), err) 403 } 404 return Config{}, err 405 } 406 c := Config{ 407 Format: format, 408 Width: conf.Width, 409 Height: conf.Height, 410 } 411 mr := io.LimitReader(io.MultiReader(&buf, r), 8<<20) 412 if format == "heic" { 413 hf := heif.Open(readerutil.NewBufferingReaderAt(mr)) 414 exifBytes, err := hf.EXIF() 415 if err != nil { 416 return c, err 417 } 418 c.HEICEXIF = exifBytes 419 mr = bytes.NewReader(exifBytes) 420 } 421 422 ex, err := exif.Decode(mr) 423 // trigger a retry when there isn't enough data for reading exif data from a tiff file 424 if exif.IsShortReadTagValueError(err) { 425 return c, io.ErrUnexpectedEOF 426 } 427 if err != nil { 428 imageDebug(fmt.Sprintf("No valid EXIF, error: %v.", err)) 429 return c, nil 430 } 431 tag, err := ex.Get(exif.Orientation) 432 if err != nil { 433 imageDebug(`No "Orientation" tag in EXIF.`) 434 return c, nil 435 } 436 orient, err := tag.Int(0) 437 if err != nil { 438 imageDebug(fmt.Sprintf("EXIF Error: %v", err)) 439 return c, nil 440 } 441 switch orient { 442 // those are the orientations that require 443 // a rotation of ±90 444 case leftSideTop, rightSideTop, rightSideBottom, leftSideBottom: 445 c.Width, c.Height = c.Height, c.Width 446 } 447 return c, nil 448} 449 450// decoder reads an image from r and modifies the image as defined by opts. 451// swapDimensions indicates the decoded image will be rotated after being 452// returned, and when interpreting opts, the post-rotation dimensions should 453// be considered. 454// The decoded image is returned in im. The registered name of the decoder 455// used is returned in format. If the image was not successfully decoded, err 456// will be non-nil. If the decoded image was made smaller, needRescale will 457// be true. 458func decode(r io.Reader, opts *DecodeOpts, swapDimensions bool) (im image.Image, format string, err error, needRescale bool) { 459 if opts == nil { 460 // Fall-back to normal decode. 461 im, format, err = image.Decode(r) 462 return im, format, err, false 463 } 464 465 var buf bytes.Buffer 466 tr := io.TeeReader(r, &buf) 467 ic, format, err := image.DecodeConfig(tr) 468 if err != nil { 469 return nil, "", err, false 470 } 471 472 mr := io.MultiReader(&buf, r) 473 b := image.Rect(0, 0, ic.Width, ic.Height) 474 sw, sh, needRescale := opts.rescaleDimensions(b, swapDimensions) 475 if !needRescale { 476 im, format, err = image.Decode(mr) 477 return im, format, err, false 478 } 479 480 imageDebug(fmt.Sprintf("Resizing from %dx%d -> %dx%d", ic.Width, ic.Height, sw, sh)) 481 if format == "cr2" { 482 // Replace mr with an io.Reader to the JPEG thumbnail embedded in a 483 // CR2 image. 484 if mr, err = cr2.NewReader(mr); err != nil { 485 return nil, "", err, false 486 } 487 format = "jpeg" 488 } 489 490 if format == "jpeg" && fastjpeg.Available() { 491 factor := fastjpeg.Factor(ic.Width, ic.Height, sw, sh) 492 if factor > 1 { 493 var buf bytes.Buffer 494 tr := io.TeeReader(mr, &buf) 495 im, err = fastjpeg.DecodeDownsample(tr, factor) 496 switch err.(type) { 497 case fastjpeg.DjpegFailedError: 498 log.Printf("Retrying with jpeg.Decode, because djpeg failed with: %v", err) 499 im, err = jpeg.Decode(io.MultiReader(&buf, mr)) 500 case nil: 501 // fallthrough to rescale() below. 502 default: 503 return nil, format, err, false 504 } 505 return rescale(im, sw, sh), format, err, true 506 } 507 } 508 509 // Fall-back to normal decode. 510 im, format, err = image.Decode(mr) 511 if err != nil { 512 return nil, "", err, false 513 } 514 return rescale(im, sw, sh), format, err, needRescale 515} 516 517// exifOrientation parses the EXIF data in r and returns the stored 518// orientation as the angle and flip necessary to transform the image. 519func exifOrientation(r io.Reader) (int, FlipDirection) { 520 var ( 521 angle int 522 flipMode FlipDirection 523 ) 524 ex, err := exif.Decode(r) 525 if err != nil { 526 imageDebug("No valid EXIF; will not rotate or flip.") 527 return 0, 0 528 } 529 tag, err := ex.Get(exif.Orientation) 530 if err != nil { 531 imageDebug(`No "Orientation" tag in EXIF; will not rotate or flip.`) 532 return 0, 0 533 } 534 orient, err := tag.Int(0) 535 if err != nil { 536 imageDebug(fmt.Sprintf("EXIF error: %v", err)) 537 return 0, 0 538 } 539 switch orient { 540 case topLeftSide: 541 // do nothing 542 case topRightSide: 543 flipMode = 2 544 case bottomRightSide: 545 angle = 180 546 case bottomLeftSide: 547 angle = 180 548 flipMode = 2 549 case leftSideTop: 550 angle = -90 551 flipMode = 2 552 case rightSideTop: 553 angle = -90 554 case rightSideBottom: 555 angle = 90 556 flipMode = 2 557 case leftSideBottom: 558 angle = 90 559 } 560 return angle, flipMode 561} 562 563// Decode decodes an image from r using the provided decoding options. 564// The Config returned is similar to the one from the image package, 565// with the addition of the Modified field which indicates if the 566// image was actually flipped, rotated, or scaled. 567// If opts is nil, the defaults are used. 568func Decode(r io.Reader, opts *DecodeOpts) (image.Image, Config, error) { 569 var ( 570 angle int 571 buf bytes.Buffer 572 c Config 573 flipMode FlipDirection 574 ) 575 576 tr := io.TeeReader(io.LimitReader(r, 2<<20), &buf) 577 if opts.useEXIF() { 578 angle, flipMode = exifOrientation(tr) 579 } else { 580 var err error 581 angle, flipMode, err = opts.forcedOrientation() 582 if err != nil { 583 return nil, c, err 584 } 585 } 586 587 // Orientation changing rotations should have their dimensions swapped 588 // when scaling. 589 var swapDimensions bool 590 switch angle { 591 case 90, -90: 592 swapDimensions = true 593 } 594 595 mr := io.MultiReader(&buf, r) 596 im, format, err, rescaled := decode(mr, opts, swapDimensions) 597 if err != nil { 598 return nil, c, err 599 } 600 c.Modified = rescaled 601 602 if angle != 0 { 603 im = rotate(im, angle) 604 c.Modified = true 605 } 606 607 if flipMode != 0 { 608 im = flip(im, flipMode) 609 c.Modified = true 610 } 611 612 c.Format = format 613 c.setBounds(im) 614 return im, c, nil 615} 616 617// Dimensions is the desired max width and height of an image. 618type Dimensions struct { 619 MaxWidth int 620 MaxHeight int 621} 622 623var convertGate = syncutil.NewGate(10) // bounds number of HEIF to JPEG subprocesses 624 625var magickHasHEIC struct { 626 sync.Mutex 627 checked bool 628 heic bool 629} 630 631// localImageMagick returns the path to the local ImageMagick "magick" binary, 632// if it's new enough. Otherwise it returns the empty string. 633func localImageMagick() string { 634 bin, err := exec.LookPath("magick") 635 if err != nil { 636 return "" 637 } 638 magickHasHEIC.Lock() 639 defer magickHasHEIC.Unlock() 640 if magickHasHEIC.checked { 641 if magickHasHEIC.heic { 642 return bin 643 } 644 return "" 645 } 646 magickHasHEIC.checked = true 647 out, err := exec.Command(bin, "-version").CombinedOutput() 648 if err != nil { 649 log.Printf("internal/images: error checking local machine's imagemagick version: %v, %s", err, out) 650 return "" 651 } 652 if strings.Contains(string(out), " heic") { 653 magickHasHEIC.heic = true 654 return bin 655 } 656 return "" 657} 658 659type NoHEICTOJPEGError struct { 660 error 661} 662 663// HEIFToJPEG converts the HEIF file in fr to JPEG. It optionally resizes it 664// to the given maxSize argument, if any. It returns the contents of the JPEG file. 665func HEIFToJPEG(fr io.Reader, maxSize *Dimensions) ([]byte, error) { 666 convertGate.Start() 667 defer convertGate.Done() 668 useDocker := false 669 bin := localImageMagick() 670 if bin == "" { 671 if err := setUpThumbnailContainer(); err != nil { 672 return nil, NoHEICTOJPEGError{fmt.Errorf("recent ImageMagick magick binary not found in PATH, and could not fallback on docker image because %v. Install a modern ImageMagick or install docker.", err)} 673 } 674 bin = "docker" 675 useDocker = true 676 } 677 678 outDir, err := ioutil.TempDir("", "perkeep-heif") 679 if err != nil { 680 return nil, err 681 } 682 defer os.RemoveAll(outDir) 683 inFile := filepath.Join(outDir, "input.heic") 684 outFile := filepath.Join(outDir, "output.jpg") 685 686 // first create the input file in tmp as heiftojpeg cannot take a piped stdin 687 f, err := os.Create(inFile) 688 if err != nil { 689 return nil, err 690 } 691 if _, err := io.Copy(f, fr); err != nil { 692 f.Close() 693 return nil, err 694 } 695 if err := f.Close(); err != nil { 696 return nil, err 697 } 698 699 // now actually run ImageMagick 700 var args []string 701 outFileArg := outFile 702 if useDocker { 703 args = append(args, "run", 704 "--rm", 705 "-v", outDir+":/out/", 706 thumbnailImage, 707 "/usr/local/bin/magick", 708 ) 709 inFile = "/out/input.heic" 710 outFileArg = "/out/output.jpg" 711 } 712 args = append(args, "convert") 713 if maxSize != nil { 714 args = append(args, "-thumbnail", fmt.Sprintf("%dx%d", maxSize.MaxWidth, maxSize.MaxHeight)) 715 } 716 args = append(args, inFile, "-colorspace", "RGB", "-auto-orient", outFileArg) 717 718 cmd := exec.Command(bin, args...) 719 t0 := time.Now() 720 if debug { 721 log.Printf("internal/images: running imagemagick heic conversion: %q %q", bin, args) 722 } 723 var buf bytes.Buffer 724 cmd.Stderr = &buf 725 if err = cmd.Run(); err != nil { 726 if debug { 727 log.Printf("internal/images: error running imagemagick heic conversion: %s", buf.Bytes()) 728 } 729 return nil, fmt.Errorf("error running imagemagick: %v, %s", err, buf.Bytes()) 730 } 731 if debug { 732 log.Printf("internal/images: ran imagemagick heic conversion in %v", time.Since(t0)) 733 } 734 return ioutil.ReadFile(outFile) 735} 736