package thumbnails import ( "bytes" "context" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" _ "golang.org/x/image/webp" // SupportedMIME nennt WebP – ohne den Dekodierer entstand dafür nie ein Vorschaubild "io" "strings" "golang.org/x/image/draw" "aether/internal/imageutil" "aether/internal/storage" ) const ( maxWidth = 256 maxHeight = 256 ) // SupportedMIME returns true if we can generate a thumbnail for this MIME type. func SupportedMIME(mime string) bool { m := strings.ToLower(mime) return strings.HasPrefix(m, "image/jpeg") || strings.HasPrefix(m, "image/png") || strings.HasPrefix(m, "image/gif") || strings.HasPrefix(m, "image/webp") } // ThumbnailKey returns the storage key for a file's thumbnail. func ThumbnailKey(fileID string) string { return "thumbs/" + fileID + ".jpg" } // Generate reads an image from src, creates a thumbnail, and stores it. // Returns nil if generation succeeds, error otherwise. func Generate(ctx context.Context, st storage.Storage, fileID, mimeType string, src io.Reader) error { if !SupportedMIME(mimeType) { return fmt.Errorf("unsupported MIME type: %s", mimeType) } // Den Anfang zwischenspeichern: Dort steht die EXIF-Drehung, und danach // muss derselbe Strom noch dekodiert werden. Nur der Anfang, nicht die // ganze Datei – ein 40-MB-Foto soll nicht zweimal im Speicher liegen. head := make([]byte, imageutil.HeadBytes) n, err := io.ReadFull(src, head) if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { return fmt.Errorf("read image head: %w", err) } head = head[:n] orientation := imageutil.Orientation(head) img, _, err := image.Decode(io.MultiReader(bytes.NewReader(head), src)) if err != nil { return fmt.Errorf("decode image: %w", err) } // Vor dem Verkleinern drehen. Andersherum stimmte das Seitenverhältnis // nicht: Ein hochkantes Foto würde als 256×128 eingepasst und danach // gedreht – aus dem Bild wären 128×256 geworden, aber mit dem Zuschnitt // eines Querformats. img = imageutil.Apply(img, orientation) thumb := resize(img, maxWidth, maxHeight) var buf bytes.Buffer if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { return fmt.Errorf("encode thumbnail: %w", err) } key := ThumbnailKey(fileID) if _, _, err := st.Put(ctx, key, &buf); err != nil { return fmt.Errorf("store thumbnail: %w", err) } return nil } func resize(src image.Image, maxW, maxH int) image.Image { bounds := src.Bounds() w, h := bounds.Dx(), bounds.Dy() if w == 0 || h == 0 { return src } scaleW := float64(maxW) / float64(w) scaleH := float64(maxH) / float64(h) scale := scaleW if scaleH < scale { scale = scaleH } if scale >= 1 { return src // already smaller than max } newW := int(float64(w) * scale) newH := int(float64(h) * scale) if newW < 1 { newW = 1 } if newH < 1 { newH = 1 } dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) return dst }