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 }