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