github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/thumbnail/thumbnail.go (about)

     1  package thumbnail
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"os"
    10  	"os/exec"
    11  	"runtime"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/model/instance"
    15  	"github.com/cozy/cozy-stack/model/job"
    16  	"github.com/cozy/cozy-stack/model/note"
    17  	"github.com/cozy/cozy-stack/model/vfs"
    18  	"github.com/cozy/cozy-stack/pkg/config/config"
    19  	"github.com/cozy/cozy-stack/pkg/consts"
    20  	"github.com/cozy/cozy-stack/pkg/couchdb"
    21  	"github.com/cozy/cozy-stack/pkg/realtime"
    22  	multierror "github.com/hashicorp/go-multierror"
    23  )
    24  
    25  type ImageMessage struct {
    26  	NoteImage *note.Image `json:"noteImage,omitempty"`
    27  	// -- or --
    28  	File   *vfs.FileDoc `json:"file,omitempty"`
    29  	Format string       `json:"format,omitempty"`
    30  }
    31  
    32  type imageEvent struct {
    33  	Verb   string       `json:"verb"`
    34  	Doc    vfs.FileDoc  `json:"doc"`
    35  	OldDoc *vfs.FileDoc `json:"old,omitempty"`
    36  }
    37  
    38  var formats = map[string]string{
    39  	"tiny":   "96x96",
    40  	"small":  "640x480>",
    41  	"medium": "1280x720>",
    42  	"large":  "1920x1080>",
    43  	"note":   "768x",
    44  }
    45  
    46  func init() {
    47  	job.AddWorker(&job.WorkerConfig{
    48  		WorkerType:   "thumbnail",
    49  		Concurrency:  runtime.NumCPU(),
    50  		MaxExecCount: 2,
    51  		Reserved:     true,
    52  		Timeout:      30 * time.Second,
    53  		WorkerFunc:   Worker,
    54  	})
    55  
    56  	job.AddWorker(&job.WorkerConfig{
    57  		WorkerType:   "thumbnailck",
    58  		Concurrency:  runtime.NumCPU(),
    59  		MaxExecCount: 1,
    60  		Reserved:     true,
    61  		Timeout:      24 * time.Hour,
    62  		WorkerFunc:   WorkerCheck,
    63  	})
    64  }
    65  
    66  // Worker is a worker that creates thumbnails for photos and images.
    67  func Worker(ctx *job.TaskContext) error {
    68  	var msg ImageMessage
    69  	if err := ctx.UnmarshalMessage(&msg); err != nil {
    70  		return err
    71  	}
    72  	log := ctx.Logger()
    73  
    74  	if msg.NoteImage != nil {
    75  		return resizeNoteImage(ctx, msg.NoteImage)
    76  	}
    77  	if msg.File != nil {
    78  		mutex := config.Lock().ReadWrite(ctx.Instance, "thumbnails/"+msg.File.ID())
    79  		if err := mutex.Lock(); err != nil {
    80  			return err
    81  		}
    82  		defer mutex.Unlock()
    83  		log.Debugf("%s %s", msg.File.ID(), msg.Format)
    84  		if _, ok := formats[msg.Format]; !ok {
    85  			return errors.New("invalid format")
    86  		}
    87  		return generateSingleThumbnail(ctx, msg.File, msg.Format)
    88  	}
    89  
    90  	var img imageEvent
    91  	if err := ctx.UnmarshalEvent(&img); err != nil {
    92  		return err
    93  	}
    94  	if img.Verb != "DELETED" && img.Doc.Trashed {
    95  		return nil
    96  	}
    97  	if img.OldDoc != nil && sameImg(&img.Doc, img.OldDoc) {
    98  		return nil
    99  	}
   100  
   101  	mutex := config.Lock().ReadWrite(ctx.Instance, "thumbnails/"+img.Doc.ID())
   102  	if err := mutex.Lock(); err != nil {
   103  		return err
   104  	}
   105  	defer mutex.Unlock()
   106  	log.Debugf("%s %s", img.Verb, img.Doc.ID())
   107  
   108  	switch img.Verb {
   109  	case "CREATED":
   110  		return generateThumbnails(ctx, &img.Doc)
   111  	case "UPDATED":
   112  		if err := removeThumbnails(ctx.Instance, &img.Doc); err != nil {
   113  			log.Debugf("failed to remove thumbnails for %s: %s", img.Doc.ID(), err)
   114  		}
   115  		return generateThumbnails(ctx, &img.Doc)
   116  	case "DELETED":
   117  		return removeThumbnails(ctx.Instance, &img.Doc)
   118  	}
   119  	return fmt.Errorf("unknown type %s for event", img.Verb)
   120  }
   121  
   122  func sameImg(doc, old *vfs.FileDoc) bool {
   123  	// XXX It is needed for a file that has just been uploaded. The first
   124  	// revision will have the size and md5sum, but is marked as trashed,
   125  	// and we have to wait for the second revision to have the file to generate
   126  	// the thumbnails
   127  	if doc.Trashed != old.Trashed {
   128  		return false
   129  	}
   130  	if doc.ByteSize != old.ByteSize {
   131  		return false
   132  	}
   133  	return bytes.Equal(doc.MD5Sum, old.MD5Sum)
   134  }
   135  
   136  type thumbnailMsg struct {
   137  	WithMetadata bool `json:"with_metadata"`
   138  }
   139  
   140  // WorkerCheck is a worker function that checks all the images to generate
   141  // missing thumbnails.
   142  func WorkerCheck(ctx *job.TaskContext) error {
   143  	var msg thumbnailMsg
   144  	if err := ctx.UnmarshalMessage(&msg); err != nil {
   145  		return err
   146  	}
   147  	fs := ctx.Instance.VFS()
   148  	fsThumb := ctx.Instance.ThumbsFS()
   149  	var errm error
   150  	_ = vfs.Walk(fs, "/", func(name string, dir *vfs.DirDoc, img *vfs.FileDoc, err error) error {
   151  		if err != nil {
   152  			return err
   153  		}
   154  		if dir != nil || img.Class != "image" {
   155  			return nil
   156  		}
   157  		allExists := true
   158  		for _, format := range vfs.ThumbnailFormatNames {
   159  			var exists bool
   160  			exists, err = fsThumb.ThumbExists(img, format)
   161  			if err != nil {
   162  				errm = multierror.Append(errm, err)
   163  				return nil
   164  			}
   165  			if !exists {
   166  				allExists = false
   167  			}
   168  		}
   169  		if !allExists {
   170  			if err = generateThumbnails(ctx, img); err != nil {
   171  				errm = multierror.Append(errm, err)
   172  			}
   173  		}
   174  		if msg.WithMetadata {
   175  			var meta *vfs.Metadata
   176  			meta, err = calculateMetadata(fs, img)
   177  			if err != nil {
   178  				errm = multierror.Append(errm, err)
   179  			}
   180  			if meta != nil {
   181  				newImg := img.Clone().(*vfs.FileDoc)
   182  				newImg.Metadata = *meta
   183  				if newImg.CozyMetadata == nil {
   184  					newImg.CozyMetadata = vfs.NewCozyMetadata(ctx.Instance.PageURL("/", nil))
   185  				} else {
   186  					newImg.CozyMetadata.UpdatedAt = time.Now()
   187  				}
   188  				if err = fs.UpdateFileDoc(img, newImg); err != nil {
   189  					errm = multierror.Append(errm, err)
   190  				}
   191  			}
   192  		}
   193  		return nil
   194  	})
   195  	return errm
   196  }
   197  
   198  func calculateMetadata(fs vfs.VFS, img *vfs.FileDoc) (*vfs.Metadata, error) {
   199  	exifP := vfs.NewMetaExtractor(img)
   200  	if exifP == nil {
   201  		return nil, nil
   202  	}
   203  	exif := *exifP
   204  	f, err := fs.OpenFile(img)
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  	defer func() {
   209  		if errc := f.Close(); err == nil {
   210  			err = errc
   211  		}
   212  	}()
   213  	_, err = io.Copy(exif, io.LimitReader(f, 128*1024))
   214  	if err != nil {
   215  		return nil, err
   216  	}
   217  	meta := exif.Result()
   218  	return &meta, nil
   219  }
   220  
   221  func generateSingleThumbnail(ctx *job.TaskContext, img *vfs.FileDoc, format string) error {
   222  	if ok := checkByteSize(img); !ok {
   223  		return nil
   224  	}
   225  
   226  	fs := ctx.Instance.ThumbsFS()
   227  	exists, err := fs.ThumbExists(img, format)
   228  	if err != nil {
   229  		return err
   230  	}
   231  	if exists {
   232  		return nil
   233  	}
   234  
   235  	var in io.Reader
   236  	in, err = ctx.Instance.VFS().OpenFile(img)
   237  	if err != nil {
   238  		return err
   239  	}
   240  
   241  	var env []string
   242  	{
   243  		var tempDir string
   244  		tempDir, err = os.MkdirTemp("", "magick")
   245  		if err == nil {
   246  			defer os.RemoveAll(tempDir)
   247  			envTempDir := fmt.Sprintf("MAGICK_TEMPORARY_PATH=%s", tempDir)
   248  			env = []string{envTempDir}
   249  		}
   250  	}
   251  	_, err = recGenerateThumb(ctx, in, fs, img, format, env, true)
   252  	return err
   253  }
   254  
   255  func generateThumbnails(ctx *job.TaskContext, img *vfs.FileDoc) error {
   256  	if ok := checkByteSize(img); !ok {
   257  		return nil
   258  	}
   259  
   260  	fs := ctx.Instance.ThumbsFS()
   261  	var in io.Reader
   262  	in, err := ctx.Instance.VFS().OpenFile(img)
   263  	if err != nil {
   264  		return err
   265  	}
   266  
   267  	var env []string
   268  	{
   269  		var tempDir string
   270  		tempDir, err = os.MkdirTemp("", "magick")
   271  		if err == nil {
   272  			defer os.RemoveAll(tempDir)
   273  			envTempDir := fmt.Sprintf("MAGICK_TEMPORARY_PATH=%s", tempDir)
   274  			env = []string{envTempDir}
   275  		}
   276  	}
   277  
   278  	if img.Class == "image" {
   279  		in, err = recGenerateThumb(ctx, in, fs, img, "large", env, false)
   280  		if err != nil {
   281  			return err
   282  		}
   283  		in, err = recGenerateThumb(ctx, in, fs, img, "medium", env, false)
   284  		if err != nil {
   285  			return err
   286  		}
   287  		in, err = recGenerateThumb(ctx, in, fs, img, "small", env, false)
   288  		if err != nil {
   289  			return err
   290  		}
   291  	}
   292  
   293  	exists, err := fs.ThumbExists(img, "tiny")
   294  	if err != nil {
   295  		return err
   296  	}
   297  	if exists {
   298  		return nil
   299  	}
   300  	_, err = recGenerateThumb(ctx, in, fs, img, "tiny", env, true)
   301  	return err
   302  }
   303  
   304  func checkByteSize(img *vfs.FileDoc) bool {
   305  	// Do not try to generate thumbnails for images that weight more than 100MB
   306  	// (or 5MB for PSDs)
   307  	var limit int64 = 100 * 1024 * 1024
   308  	if img.Mime == "image/vnd.adobe.photoshop" {
   309  		limit = 5 * 1024 * 1024
   310  	}
   311  	return img.ByteSize < limit
   312  }
   313  
   314  func recGenerateThumb(ctx *job.TaskContext, in io.Reader, fs vfs.Thumbser, img *vfs.FileDoc, format string, env []string, noOuput bool) (r io.Reader, err error) {
   315  	defer func() {
   316  		if inCloser, ok := in.(io.Closer); ok {
   317  			if errc := inCloser.Close(); errc != nil && err == nil {
   318  				err = errc
   319  			}
   320  		}
   321  	}()
   322  	th, err := fs.CreateThumb(img, format)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	defer func() {
   327  		if err != nil {
   328  			_ = th.Abort()
   329  		} else {
   330  			_ = th.Commit()
   331  			doc := &couchdb.JSONDoc{
   332  				M: map[string]interface{}{
   333  					"_id":    img.ID(),
   334  					"format": format,
   335  				},
   336  				Type: consts.Thumbnails,
   337  			}
   338  			go realtime.GetHub().Publish(ctx.Instance, realtime.EventCreate, doc, nil)
   339  		}
   340  	}()
   341  	var buffer *bytes.Buffer
   342  	var out io.Writer
   343  	if noOuput {
   344  		out = th
   345  	} else {
   346  		buffer = new(bytes.Buffer)
   347  		out = io.MultiWriter(th, buffer)
   348  	}
   349  	err = generateThumb(ctx, in, out, img.ID(), format, env)
   350  	if err != nil {
   351  		return nil, err
   352  	}
   353  	return buffer, nil
   354  }
   355  
   356  // The thumbnails are generated with ImageMagick, because it has the better
   357  // compromise for speed, quality and ease of deployment.
   358  // See https://github.com/fawick/speedtest-resize
   359  //
   360  // We are using some complicated ImageMagick options to optimize the speed and
   361  // quality of the generated thumbnails.
   362  // See https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/
   363  func generateThumb(ctx *job.TaskContext, in io.Reader, out io.Writer, fileID string, format string, env []string) error {
   364  	convertCmd := config.GetConfig().Jobs.ImageMagickConvertCmd
   365  	if convertCmd == "" {
   366  		convertCmd = "convert"
   367  	}
   368  	quality := "82" // A good compromise between file size and quality
   369  	if format == "tiny" {
   370  		quality = "99" // At small resolution, we want a very good quality
   371  	}
   372  	args := []string{
   373  		"-limit", "Memory", "2GB",
   374  		"-limit", "Map", "3GB",
   375  		"-[0]",         // Takes the input from stdin
   376  		"-auto-orient", // Rotate image according to the EXIF metadata
   377  		"-strip",       // Strip the EXIF metadata
   378  		"-quality", quality,
   379  		"-interlace", "none", // Don't use progressive JPEGs, they are heavier
   380  		"-thumbnail", formats[format], // Makes a thumbnail that fits inside the given format
   381  		"-background", "white", // Use white for the background
   382  		"-alpha", "remove", // JPEGs don't have an alpha channel
   383  		"-colorspace", "sRGB", // Use the colorspace recommended for web, sRGB
   384  		"jpg:-", // Send the output on stdout, in JPEG format
   385  	}
   386  	var stderr bytes.Buffer
   387  	ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
   388  	defer cancel()
   389  	cmd := exec.CommandContext(ctxWithTimeout, convertCmd, args...)
   390  	cmd.Env = env
   391  	cmd.Stdin = in
   392  	cmd.Stdout = out
   393  	cmd.Stderr = &stderr
   394  	if err := cmd.Run(); err != nil {
   395  		// Truncate very long messages
   396  		msg := stderr.String()
   397  		if len(msg) > 4000 {
   398  			msg = msg[:4000]
   399  		}
   400  		ctx.Logger().
   401  			WithField("stderr", msg).
   402  			WithField("file_id", fileID).
   403  			Errorf("imagemagick failed: %s", err)
   404  		return err
   405  	}
   406  	return nil
   407  }
   408  
   409  func removeThumbnails(i *instance.Instance, img *vfs.FileDoc) error {
   410  	return i.ThumbsFS().RemoveThumbs(img, vfs.ThumbnailFormatNames)
   411  }
   412  
   413  func resizeNoteImage(ctx *job.TaskContext, img *note.Image) error {
   414  	fs := ctx.Instance.ThumbsFS()
   415  	in, err := fs.OpenNoteThumb(img.ID(), consts.NoteImageOriginalFormat)
   416  	if err != nil {
   417  		return err
   418  	}
   419  	defer func() {
   420  		if errc := in.Close(); errc != nil && err == nil {
   421  			err = errc
   422  		}
   423  	}()
   424  
   425  	var env []string
   426  	{
   427  		tempDir, err := os.MkdirTemp("", "magick")
   428  		if err == nil {
   429  			defer os.RemoveAll(tempDir)
   430  			envTempDir := fmt.Sprintf("MAGICK_TEMPORARY_PATH=%s", tempDir)
   431  			env = []string{envTempDir}
   432  		}
   433  	}
   434  
   435  	var th vfs.ThumbFiler
   436  	th, err = fs.CreateNoteThumb(img.ID(), "image/jpeg", consts.NoteImageThumbFormat)
   437  	if err != nil {
   438  		return err
   439  	}
   440  
   441  	out := th
   442  	if err = generateThumb(ctx, in, out, img.ID(), "note", env); err != nil {
   443  		return err
   444  	}
   445  
   446  	if err = th.Commit(); err != nil {
   447  		return err
   448  	}
   449  
   450  	img.ToResize = false
   451  	_ = couchdb.UpdateDoc(ctx.Instance, img)
   452  
   453  	event := note.Event{
   454  		"width":   note.MaxWidth,
   455  		"height":  img.Height * note.MaxWidth / img.Width,
   456  		"mime":    "image/jpeg",
   457  		"doctype": consts.NotesImages,
   458  	}
   459  	event.SetID(img.ID())
   460  	note.PublishThumbnail(ctx.Instance, event)
   461  	return nil
   462  }