Files
Aether/backend/internal/thumbnails/thumbnails.go
T
marcandClaude Opus 5 4bd9a6ca56 fix(thumbnails): EXIF-Drehung auswerten, hochkant blieb quer
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>
2026-08-25 14:08:10 +02:00

114 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}