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>
This commit is contained in:
1 parent
edefce3572
commit
4bd9a6ca56
10 files changed
+514
-2
No files matched your search
+28
@@ -1442,6 +1442,34 @@ könnte.
|
||||
|
||||
- [ ] Am Trackpad gegenprüfen (Zwei-Finger-Zoom meldet sich als Strg + Rad)
|
||||
|
||||
## Hochkant-Fotos lagen im Vorschaubild quer (24.08.2026)
|
||||
|
||||
Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als
|
||||
EXIF-Notiz daneben. Browser befolgen sie von selbst – Go nicht. Gemessen an
|
||||
einem Testbild mit 400×200 Bildpunkten und Drehung 6:
|
||||
|
||||
| | vorher | nachher |
|
||||
|---|---|---|
|
||||
| große Ansicht (Browser) | 200×400 hochkant ✅ | unverändert |
|
||||
| Vorschaubild (Server) | **256×128 quer** ❌ | 128×256 hochkant ✅ |
|
||||
|
||||
Alle acht Fälle, beide Byte-Reihenfolgen (II und MM):
|
||||
|
||||
- Drehung 1 und 3 bleiben quer, 6 und 8 werden hochkant – Seitenverhältnis
|
||||
allein genügt aber nicht als Beweis, deshalb ein gelber Balken als Marke:
|
||||
bei 6 landet er **oben**, bei 8 **unten**. Beide Bilder angesehen.
|
||||
- Leere Datei, Text statt JPEG, abgeschnittener EXIF-Block, Drehung 0 und 99
|
||||
ergeben alle 1 („nicht drehen") statt eines Fehlers.
|
||||
- Bei Drehung 1 entsteht keine Kopie des Bildes.
|
||||
|
||||
Bestehende Vorschaubilder ändern sich davon **nicht** – sie liegen fertig im
|
||||
Speicher. Dafür neu: Admin → Audit → „Rebuild image previews".
|
||||
Gemessen: 6 Bilder, 6 neu erzeugt, 0 Fehler; das quer liegende Testbild war
|
||||
danach hochkant.
|
||||
|
||||
- [ ] Auf der produktiven Instanz einmal „Rebuild" drücken und mit echten
|
||||
Handy-Fotos gegenprüfen
|
||||
|
||||
## Automatisiert geprüft (22.08.2026)
|
||||
|
||||
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
|
||||
|
||||
@@ -529,6 +529,9 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
// Vorschaubilder neu erzeugen – nötig für alles, was vor der
|
||||
// Auswertung der EXIF-Drehung hochgeladen wurde.
|
||||
r.Post("/admin/thumbnails/rebuild", adminHandler.RebuildThumbnails)
|
||||
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
|
||||
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
|
||||
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/thumbnails"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
/*
|
||||
POST /api/v1/admin/thumbnails/rebuild – Vorschaubilder neu erzeugen.
|
||||
|
||||
Anlass: Bis heute wurde die EXIF-Drehung beim Erzeugen nicht ausgewertet.
|
||||
Jedes hochkant aufgenommene Foto liegt im bestehenden Bestand deshalb quer,
|
||||
während dasselbe Bild in der großen Ansicht aufrecht steht – der Browser
|
||||
befolgt die Notiz von selbst, der Server tat es nicht.
|
||||
|
||||
Beim Hochladen entstehen Vorschaubilder von allein; für das, was schon da ist,
|
||||
braucht es diesen einen Anstoß. Er läuft im Vordergrund und meldet Zahlen:
|
||||
Ein Vorgang, der den halben Bestand neu schreibt, soll nicht stumm im
|
||||
Hintergrund verschwinden.
|
||||
*/
|
||||
func (h *Handler) RebuildThumbnails(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
|
||||
list, err := h.files.ImageFiles(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list images")
|
||||
return
|
||||
}
|
||||
|
||||
var rebuilt, failed int
|
||||
for _, f := range list {
|
||||
if !thumbnails.SupportedMIME(f.MimeType) {
|
||||
continue
|
||||
}
|
||||
rc, err := h.storage.Get(r.Context(), f.StorageKey)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
err = thumbnails.Generate(r.Context(), h.storage, f.ID.String(), f.MimeType, rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
// Auch setzen, wenn es schon stand: Dateien, bei denen die Erzeugung
|
||||
// beim Hochladen fehlschlug, bekommen so nachträglich eines.
|
||||
if err := h.files.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
rebuilt++
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
fmt.Sprintf("thumbnails rebuilt: %d of %d images, %d failed",
|
||||
rebuilt, len(list), failed))
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"images": len(list),
|
||||
"rebuilt": rebuilt,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/imageutil"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
@@ -45,10 +46,22 @@ const MaxBytes = 4 << 20
|
||||
|
||||
// Process liest ein Bild und gibt ein quadratisches PNG zurück.
|
||||
func Process(r io.Reader) ([]byte, error) {
|
||||
src, _, err := image.Decode(io.LimitReader(r, MaxBytes))
|
||||
// Dasselbe wie beim Vorschaubild: Der Anfang trägt die EXIF-Drehung, und
|
||||
// ein Profilbild kommt oft direkt aus einer Telefonkamera – also quer
|
||||
// gespeichert mit einer Notiz daneben.
|
||||
head := make([]byte, imageutil.HeadBytes)
|
||||
n, herr := io.ReadFull(io.LimitReader(r, MaxBytes), head)
|
||||
if herr != nil && herr != io.EOF && herr != io.ErrUnexpectedEOF {
|
||||
return nil, fmt.Errorf("bild nicht lesbar: %w", herr)
|
||||
}
|
||||
head = head[:n]
|
||||
|
||||
src, _, err := image.Decode(io.MultiReader(
|
||||
bytes.NewReader(head), io.LimitReader(r, MaxBytes-int64(n))))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bild nicht lesbar: %w", err)
|
||||
}
|
||||
src = imageutil.Apply(src, imageutil.Orientation(head))
|
||||
b := src.Bounds()
|
||||
if b.Dx() <= 0 || b.Dy() <= 0 {
|
||||
return nil, fmt.Errorf("bild ist leer")
|
||||
|
||||
@@ -533,3 +533,29 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ImageFiles nennt alle nicht gelöschten Dateien, aus denen ein Vorschaubild
|
||||
// entstehen kann – für das Neuerzeugen von Hand.
|
||||
//
|
||||
// Ohne Papierkorb: Für eine Datei, die man weggeworfen hat, lohnt keine Runde
|
||||
// durch den Dekodierer.
|
||||
func (s *Store) ImageFiles(ctx context.Context) ([]*File, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT `+fileCols+`
|
||||
FROM files
|
||||
WHERE is_deleted = false AND mime_type LIKE 'image/%'
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query image files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
if err := scanFile(rows, &f); err != nil {
|
||||
return nil, fmt.Errorf("scan image file: %w", err)
|
||||
}
|
||||
out = append(out, &f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Package imageutil enthält, was mehrere Bildpfade teilen: heute die
|
||||
// EXIF-Drehung.
|
||||
package imageutil
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image"
|
||||
)
|
||||
|
||||
// HeadBytes ist die Menge, die zum Finden der Drehung gelesen werden muss.
|
||||
// Der EXIF-Block steht unmittelbar hinter dem Dateianfang; 64 KB decken auch
|
||||
// Kameras ab, die ein Vorschaubild mit hineinlegen.
|
||||
const HeadBytes = 64 << 10
|
||||
|
||||
/*
|
||||
Orientation liest die EXIF-Drehung (1–8) aus dem Anfang einer JPEG-Datei.
|
||||
Alles Unklare ergibt 1, also „nicht drehen" – ein unlesbarer Block ist kein
|
||||
Grund, ein Bild zu verweigern.
|
||||
|
||||
**Warum von Hand statt mit einer Bibliothek:** Gebraucht wird genau ein Feld.
|
||||
Der Weg dorthin ist kurz und liegt fest: JPEG-Segmente durchgehen bis APP1,
|
||||
dort "Exif\0\0", dahinter ein TIFF-Kopf, darin der Verzeichniseintrag mit der
|
||||
Kennung 0x0112.
|
||||
|
||||
**Warum es überhaupt gebraucht wird:** Kameras und Telefone speichern die
|
||||
Bildpunkte fast immer quer und legen die Drehung als Notiz daneben. Browser
|
||||
befolgen sie von selbst – Go nicht. Ohne diese Auswertung liegt jedes hochkant
|
||||
aufgenommene Foto im Vorschaubild auf der Seite, während dasselbe Foto in der
|
||||
großen Ansicht aufrecht steht.
|
||||
*/
|
||||
func Orientation(head []byte) int {
|
||||
if len(head) < 4 || head[0] != 0xFF || head[1] != 0xD8 { // SOI
|
||||
return 1
|
||||
}
|
||||
i := 2
|
||||
for i+4 <= len(head) {
|
||||
if head[i] != 0xFF {
|
||||
return 1 // kein Segmentanfang mehr – hier ist nichts zu holen
|
||||
}
|
||||
marker := head[i+1]
|
||||
// Bildinhalt erreicht: ab hier kommt kein EXIF mehr.
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
return 1
|
||||
}
|
||||
size := int(binary.BigEndian.Uint16(head[i+2 : i+4]))
|
||||
if size < 2 || i+2+size > len(head) {
|
||||
return 1
|
||||
}
|
||||
if marker == 0xE1 {
|
||||
if o, ok := orientationInAPP1(head[i+4 : i+2+size]); ok {
|
||||
return o
|
||||
}
|
||||
}
|
||||
i += 2 + size
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func orientationInAPP1(seg []byte) (int, bool) {
|
||||
const hdr = "Exif\x00\x00"
|
||||
if len(seg) < len(hdr)+8 || string(seg[:len(hdr)]) != hdr {
|
||||
return 0, false
|
||||
}
|
||||
tiff := seg[len(hdr):]
|
||||
|
||||
var bo binary.ByteOrder
|
||||
switch {
|
||||
case tiff[0] == 'I' && tiff[1] == 'I':
|
||||
bo = binary.LittleEndian
|
||||
case tiff[0] == 'M' && tiff[1] == 'M':
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 42 {
|
||||
return 0, false
|
||||
}
|
||||
off := int(bo.Uint32(tiff[4:8]))
|
||||
if off < 8 || off+2 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
count := int(bo.Uint16(tiff[off : off+2]))
|
||||
entry := off + 2
|
||||
for n := 0; n < count; n++ {
|
||||
if entry+12 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[entry:entry+2]) == 0x0112 {
|
||||
// Der Wert ist ein SHORT und steht deshalb im Eintrag selbst,
|
||||
// nicht anderswo in der Datei.
|
||||
v := int(bo.Uint16(tiff[entry+8 : entry+10]))
|
||||
if v >= 1 && v <= 8 {
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
entry += 12
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Apply dreht und spiegelt ein Bild so, wie die EXIF-Notiz es vorsieht.
|
||||
// Bei 1 (und allem Unbekannten) kommt das Bild unverändert zurück – dann
|
||||
// entsteht auch keine Kopie.
|
||||
func Apply(img image.Image, orientation int) image.Image {
|
||||
if orientation <= 1 || orientation > 8 {
|
||||
return img
|
||||
}
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Die vier Fälle ab 5 vertauschen Breite und Höhe.
|
||||
outW, outH := w, h
|
||||
if orientation >= 5 {
|
||||
outW, outH = h, w
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
||||
|
||||
// Für jeden Zielpunkt den Quellpunkt bestimmen. Andersherum (Quelle →
|
||||
// Ziel) bliebe bei ungeraden Größen gelegentlich eine Lücke.
|
||||
for y := 0; y < outH; y++ {
|
||||
for x := 0; x < outW; x++ {
|
||||
var sx, sy int
|
||||
switch orientation {
|
||||
case 2: // waagerecht gespiegelt
|
||||
sx, sy = w-1-x, y
|
||||
case 3: // 180 Grad
|
||||
sx, sy = w-1-x, h-1-y
|
||||
case 4: // senkrecht gespiegelt
|
||||
sx, sy = x, h-1-y
|
||||
case 5: // an der Hauptdiagonalen gespiegelt
|
||||
sx, sy = y, x
|
||||
case 6: // 90 Grad im Uhrzeigersinn
|
||||
sx, sy = y, h-1-x
|
||||
case 7: // an der Nebendiagonalen gespiegelt
|
||||
sx, sy = w-1-y, h-1-x
|
||||
case 8: // 90 Grad gegen den Uhrzeigersinn
|
||||
sx, sy = w-1-y, x
|
||||
}
|
||||
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package imageutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// jpegMitDrehung baut eine echte JPEG-Datei mit EXIF-Block. Von Hand, damit
|
||||
// der Test keine Abhängigkeit braucht und genau das prüft, was Kameras
|
||||
// schreiben: APP1 direkt hinter dem Dateianfang.
|
||||
func jpegMitDrehung(t *testing.T, o uint16, big bool) []byte {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
if err := jpeg.Encode(&body, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := body.Bytes() // beginnt mit FFD8
|
||||
|
||||
var bo binary.AppendByteOrder = binary.LittleEndian
|
||||
order := []byte{'I', 'I'}
|
||||
if big {
|
||||
bo = binary.BigEndian
|
||||
order = []byte{'M', 'M'}
|
||||
}
|
||||
tif := make([]byte, 0, 26)
|
||||
tif = append(tif, order...)
|
||||
tif = bo.AppendUint16(tif, 42)
|
||||
tif = bo.AppendUint32(tif, 8) // Verzeichnis beginnt direkt dahinter
|
||||
tif = bo.AppendUint16(tif, 1) // ein Eintrag
|
||||
tif = bo.AppendUint16(tif, 0x0112)
|
||||
tif = bo.AppendUint16(tif, 3) // SHORT
|
||||
tif = bo.AppendUint32(tif, 1) // ein Wert
|
||||
tif = bo.AppendUint16(tif, o)
|
||||
tif = append(tif, 0, 0) // Auffüllung auf vier Byte
|
||||
tif = bo.AppendUint32(tif, 0) // kein weiteres Verzeichnis
|
||||
|
||||
payload := append([]byte("Exif\x00\x00"), tif...)
|
||||
seg := []byte{0xFF, 0xE1}
|
||||
seg = binary.BigEndian.AppendUint16(seg, uint16(len(payload)+2))
|
||||
seg = append(seg, payload...)
|
||||
|
||||
out := append([]byte{}, raw[:2]...) // SOI
|
||||
out = append(out, seg...)
|
||||
return append(out, raw[2:]...)
|
||||
}
|
||||
|
||||
func TestOrientationLiestAlleFaelle(t *testing.T) {
|
||||
for o := uint16(1); o <= 8; o++ {
|
||||
for _, big := range []bool{false, true} {
|
||||
got := Orientation(jpegMitDrehung(t, o, big))
|
||||
if got != int(o) {
|
||||
name := "II"
|
||||
if big {
|
||||
name = "MM"
|
||||
}
|
||||
t.Errorf("%s Drehung %d gelesen als %d", name, o, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Was keine Drehung trägt oder kaputt ist, muss 1 ergeben – niemals einen
|
||||
// Fehler und niemals einen Zufallswert.
|
||||
func TestOrientationBleibtBeiEinsWennUnklar(t *testing.T) {
|
||||
var ohne bytes.Buffer
|
||||
if err := jpeg.Encode(&ohne, image.NewGray(image.Rect(0, 0, 4, 4)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mitDrehung := jpegMitDrehung(t, 6, false)
|
||||
|
||||
faelle := map[string][]byte{
|
||||
"leer": {},
|
||||
"kein JPEG": []byte("das hier ist ein Text"),
|
||||
"nur SOI": {0xFF, 0xD8},
|
||||
"ohne EXIF": ohne.Bytes(),
|
||||
"abgeschnitten": mitDrehung[:12],
|
||||
"Laenge zu gross": append([]byte{0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF}, []byte("Exif\x00\x00")...),
|
||||
"Drehung 0": jpegMitDrehung(t, 0, false),
|
||||
"Drehung 99": jpegMitDrehung(t, 99, false),
|
||||
}
|
||||
for name, data := range faelle {
|
||||
if got := Orientation(data); got != 1 {
|
||||
t.Errorf("%s ergab %d statt 1", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Bild mit einer eindeutigen Ecke: Nur so lässt sich prüfen, dass nicht
|
||||
// bloß Breite und Höhe getauscht wurden, sondern richtig herum gedreht.
|
||||
func markiert(w, h int) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{0, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // oben links rot
|
||||
return img
|
||||
}
|
||||
|
||||
func TestApplyDrehtRichtigHerum(t *testing.T) {
|
||||
const w, h = 4, 2
|
||||
src := markiert(w, h)
|
||||
|
||||
// Wo landet der rote Punkt von oben links?
|
||||
erwartet := map[int][2]int{
|
||||
1: {0, 0},
|
||||
2: {w - 1, 0}, // waagerecht gespiegelt
|
||||
3: {w - 1, h - 1}, // 180 Grad
|
||||
4: {0, h - 1}, // senkrecht gespiegelt
|
||||
5: {0, 0}, // Hauptdiagonale
|
||||
6: {h - 1, 0}, // 90 Grad im Uhrzeigersinn: links oben → rechts oben
|
||||
7: {h - 1, w - 1},
|
||||
8: {0, w - 1}, // 90 Grad dagegen: links oben → links unten
|
||||
}
|
||||
for o, want := range erwartet {
|
||||
out := Apply(src, o)
|
||||
b := out.Bounds()
|
||||
wantW, wantH := w, h
|
||||
if o >= 5 {
|
||||
wantW, wantH = h, w
|
||||
}
|
||||
if b.Dx() != wantW || b.Dy() != wantH {
|
||||
t.Errorf("Drehung %d: %dx%d, erwartet %dx%d", o, b.Dx(), b.Dy(), wantW, wantH)
|
||||
continue
|
||||
}
|
||||
r, _, _, _ := out.At(want[0], want[1]).RGBA()
|
||||
if r < 0x8000 {
|
||||
t.Errorf("Drehung %d: an %v steht kein roter Punkt", o, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bei 1 darf kein neues Bild entstehen – das spart bei jedem Vorschaubild
|
||||
// eine vollständige Kopie.
|
||||
func TestApplyLaesstEinsUnberuehrt(t *testing.T) {
|
||||
src := markiert(3, 3)
|
||||
if Apply(src, 1) != src {
|
||||
t.Error("Drehung 1 hat kopiert")
|
||||
}
|
||||
if Apply(src, 0) != src || Apply(src, 9) != src {
|
||||
t.Error("unbekannte Drehung hat kopiert")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
"aether/internal/imageutil"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
@@ -44,11 +45,28 @@ func Generate(ctx context.Context, st storage.Storage, fileID, mimeType string,
|
||||
return fmt.Errorf("unsupported MIME type: %s", mimeType)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(src)
|
||||
// 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
|
||||
|
||||
@@ -830,6 +830,12 @@ export const adminApi = {
|
||||
freed_bytes: number; skipped_young: number
|
||||
}>('POST', '/admin/gc'),
|
||||
|
||||
/** Vorschaubilder neu erzeugen – für alles, was vor der Auswertung der
|
||||
* EXIF-Drehung hochgeladen wurde und deshalb quer liegt. */
|
||||
rebuildThumbnails: () => request<{
|
||||
images: number; rebuilt: number; failed: number
|
||||
}>('POST', '/admin/thumbnails/rebuild'),
|
||||
|
||||
/** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die
|
||||
* Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */
|
||||
ocrStatus: () => request<OcrStatus>('GET', '/admin/ocr'),
|
||||
|
||||
@@ -195,6 +195,60 @@ function StorageCleanup() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Vorschaubilder entstehen beim Hochladen von selbst. Dieser Knopf ist für
|
||||
* den Bestand: Bis heute wurde die EXIF-Drehung nicht ausgewertet, hochkant
|
||||
* aufgenommene Fotos liegen als Vorschaubild deshalb quer. */
|
||||
function RebuildThumbnails() {
|
||||
const [running, setRunning] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
|
||||
async function run() {
|
||||
setRunning(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const r = await adminApi.rebuildThumbnails()
|
||||
setResult(
|
||||
r.images === 0
|
||||
? 'No images stored yet'
|
||||
: `${r.rebuilt} of ${r.images} previews rebuilt` + (r.failed ? `, ${r.failed} failed` : ''),
|
||||
)
|
||||
} catch {
|
||||
setResult('Rebuild failed')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Rebuild image previews
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
{result ?? 'Regenerates every stored preview. Needed once: photos taken in portrait used to end up sideways, because the rotation flag was ignored.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={running}
|
||||
style={{
|
||||
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
|
||||
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{running ? 'Rebuilding…' : 'Rebuild'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditTab() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -221,6 +275,7 @@ export default function AuditTab() {
|
||||
<div>
|
||||
<IndexingStatus />
|
||||
<StorageCleanup />
|
||||
<RebuildThumbnails />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<select
|
||||
|
||||
Reference in new issue
Block a user