Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht. Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im Vorschaubild auf der Seite. Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek: Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld. Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal im Speicher liegt. Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes Foto den Zuschnitt eines Querformats. Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu: POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich. Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
3.0 KiB
Go
114 lines
3.0 KiB
Go
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
|
||
}
|