feat(backend): generate thumbnails/previews via vipsthumbnail

Use vipsthumbnail as the primary still-image path for both thumbnails
and previews (shared serveGenerated), falling back to the pure-Go
imaging pipeline when vips isn't on PATH. vips shrinks on load (e.g.
JPEG DCT scaling), so a 200+ Mpx photo is resized in a fraction of the
memory and CPU of a full in-process decode and no longer exceeds the
decode cap — the source that previously got only a placeholder now gets
a real thumbnail and preview. The output JPEG is written straight to the
cache (atomic temp→rename), fit within the target box and never upscaled.

The in-process pixel cap now guards only the pure-Go fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:25:55 +03:00
parent e694f17be9
commit 384386a34e
3 changed files with 153 additions and 11 deletions
+4 -3
View File
@@ -35,9 +35,10 @@ type Config struct {
ThumbHeight int ThumbHeight int
PreviewWidth int PreviewWidth int
PreviewHeight int PreviewHeight int
// ThumbMaxPixels caps the pixel count of a source image we will decode into // ThumbMaxPixels caps the pixel count of a source image decoded in-process by
// memory to generate a thumbnail/preview (a decode bombs guard, and a memory // the pure-Go fallback (a decompression-bomb guard and memory bound); larger
// bound). Larger images fall back to a placeholder. // images then get a placeholder. It does not apply when vipsthumbnail is
// installed, which shrinks on load regardless of source size.
ThumbMaxPixels int ThumbMaxPixels int
// ThumbConcurrency bounds how many thumbnails/previews are generated at once, // ThumbConcurrency bounds how many thumbnails/previews are generated at once,
// so a burst of large images can't saturate every core or exhaust RAM. 0 = // so a burst of large images can't saturate every core or exhaust RAM. 0 =
+67 -8
View File
@@ -160,14 +160,15 @@ func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser,
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// serveGenerated is the shared implementation for Thumbnail and Preview. Both // serveGenerated is the shared implementation for Thumbnail and Preview. Both
// fit the source within maxW×maxH with imaging.Fit, preserving the aspect ratio // fit the source within maxW×maxH preserving the aspect ratio (no crop, no
// (no crop, no upscale); they differ only in the configured dimensions. // upscale); they differ only in the configured dimensions.
// //
// Resolution order: // Resolution order:
// 1. Return cached JPEG if present. // 1. Return cached JPEG if present.
// 2. Decode as still image (JPEG/PNG/GIF via imaging). // 2. vipsthumbnail (shrink-on-load; the primary still-image path).
// 3. Extract a frame with ffmpeg (video files). // 3. Pure-Go decode + imaging.Fit (fallback when vips is absent).
// 4. Solid-colour placeholder (archives, unrecognised formats, etc.). // 4. Extract a frame with ffmpeg (video files).
// 5. Solid-colour placeholder (archives, unrecognised formats, etc.).
func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) { func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
// Fast path: cache hit. // Fast path: cache hit.
if f, err := os.Open(cachePath); err == nil { if f, err := os.Open(cachePath); err == nil {
@@ -198,9 +199,20 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat
return f, nil return f, nil
} }
// 1. Try still-image decode (JPEG/PNG/GIF), rejecting decompression bombs. // Primary path: vipsthumbnail. It shrinks on load (e.g. JPEG DCT scaling), so
// 2. Try video frame extraction via ffmpeg. // even a 200+ Mpx photo is thumbnailed in a fraction of the memory and CPU of a
// 3. Fall back to placeholder. // full in-process decode, writing the final JPEG straight to the cache. Falls
// through when vips is absent or can't read the source (e.g. a video).
if vipsThumbnailPath != "" {
if rc, err := s.vipsThumbnail(ctx, srcPath, cachePath, maxW, maxH); err == nil {
return rc, nil
}
}
// Fallback pipeline (pure Go):
// 1. Still-image decode (JPEG/PNG/GIF), rejecting oversized rasters.
// 2. Video frame extraction via ffmpeg.
// 3. Solid-colour placeholder.
var img image.Image var img image.Image
if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil { if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil {
img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos) img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos)
@@ -283,6 +295,53 @@ func decodeImageLimited(path string, maxPixels int) (image.Image, error) {
return imaging.Decode(f, imaging.AutoOrientation(true)) return imaging.Decode(f, imaging.AutoOrientation(true))
} }
// vipsThumbnailPath is the resolved path to the vipsthumbnail CLI, or "" when it
// isn't installed — in which case generation falls back to the pure-Go pipeline.
var vipsThumbnailPath, _ = exec.LookPath("vipsthumbnail")
// vipsThumbnail generates a JPEG thumbnail with the vipsthumbnail CLI, writing it
// straight to cachePath via an atomic temp→rename. vips decodes large images at a
// reduced scale (shrink-on-load), so this costs a fraction of the memory and CPU
// of a full in-process decode. The result is fit within maxW×maxH and never
// upscaled (the ">" size modifier). Returns an error for inputs vips can't read
// (e.g. videos) so the caller can fall back.
func (s *DiskStorage) vipsThumbnail(ctx context.Context, srcPath, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
tmp, err := os.CreateTemp(filepath.Dir(cachePath), ".vips-*.jpg")
if err != nil {
return nil, fmt.Errorf("storage: create temp file: %w", err)
}
tmpName := tmp.Name()
_ = tmp.Close()
cmd := exec.CommandContext(ctx, vipsThumbnailPath,
srcPath,
"--size", fmt.Sprintf("%dx%d>", maxW, maxH),
"--output", tmpName+"[Q=85]",
)
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("vipsthumbnail: %w", err)
}
if fi, err := os.Stat(tmpName); err != nil || fi.Size() == 0 {
os.Remove(tmpName)
return nil, fmt.Errorf("vipsthumbnail: no output produced")
}
if err := os.Rename(tmpName, cachePath); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("storage: rename cache file: %w", err)
}
f, err := os.Open(cachePath)
if err != nil {
return nil, fmt.Errorf("storage: open cache file: %w", err)
}
return f, nil
}
// extractVideoFrame uses ffmpeg to extract a single frame from a video file. // extractVideoFrame uses ffmpeg to extract a single frame from a video file.
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out // It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
// as PNG. If the video is shorter than 1 s the seek is silently ignored by // as PNG. If the video is shorter than 1 s the seek is silently ignored by
+82
View File
@@ -97,3 +97,85 @@ func TestThumbnailGeneratesAndCaches(t *testing.T) {
} }
rc2.Close() rc2.Close()
} }
// TestThumbnailFallbackWithoutVips forces the pure-Go pipeline (as if vips were
// not installed) and verifies generation still produces the source image.
func TestThumbnailFallbackWithoutVips(t *testing.T) {
orig := vipsThumbnailPath
vipsThumbnailPath = ""
t.Cleanup(func() { vipsThumbnailPath = orig })
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
writeTestImage(t, filepath.Join(files, id.String()), 100, 80)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
if b := out.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected thumbnail size %v", b.Size())
}
r, g, b, _ := out.At(50, 40).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("fallback produced a placeholder, not the source (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
}
// TestPreviewGeneratesAndCaches verifies Preview runs through the same pipeline
// with the preview dimensions and its own cache file (not the thumbnail's).
func TestPreviewGeneratesAndCaches(t *testing.T) {
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
// Larger than the thumbnail box but within the preview box, so the preview
// keeps full resolution where a thumbnail would shrink it.
writeTestImage(t, filepath.Join(files, id.String()), 400, 300)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Preview(context.Background(), id)
if err != nil {
t.Fatalf("Preview: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode preview: %v", err)
}
// 400×300 fits within 1920×1080, so the preview is not downscaled.
if b := out.Bounds(); b.Dx() != 400 || b.Dy() != 300 {
t.Fatalf("unexpected preview size %v", b.Size())
}
r, g, b, _ := out.At(200, 150).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("preview is not the source image (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
// The preview cache must be written, and the thumbnail cache must not — they
// are separate files served by the same code with different dimensions.
if _, err := os.Stat(s.previewCachePath(id)); err != nil {
t.Fatalf("preview cache not written: %v", err)
}
if _, err := os.Stat(s.thumbCachePath(id)); err == nil {
t.Fatal("thumbnail cache should not exist after a preview-only request")
}
}