From 384386a34e8970632286bcded17e66a9fa47d422 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Fri, 12 Jun 2026 01:25:55 +0300 Subject: [PATCH] feat(backend): generate thumbnails/previews via vipsthumbnail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/config/config.go | 7 ++- backend/internal/storage/disk.go | 75 +++++++++++++++++++++--- backend/internal/storage/disk_test.go | 82 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 91c15c6..5055063 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -35,9 +35,10 @@ type Config struct { ThumbHeight int PreviewWidth int PreviewHeight int - // ThumbMaxPixels caps the pixel count of a source image we will decode into - // memory to generate a thumbnail/preview (a decode bombs guard, and a memory - // bound). Larger images fall back to a placeholder. + // ThumbMaxPixels caps the pixel count of a source image decoded in-process by + // the pure-Go fallback (a decompression-bomb guard and memory bound); larger + // images then get a placeholder. It does not apply when vipsthumbnail is + // installed, which shrinks on load regardless of source size. ThumbMaxPixels int // 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 = diff --git a/backend/internal/storage/disk.go b/backend/internal/storage/disk.go index b57ad6e..365e3fb 100644 --- a/backend/internal/storage/disk.go +++ b/backend/internal/storage/disk.go @@ -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 -// fit the source within maxW×maxH with imaging.Fit, preserving the aspect ratio -// (no crop, no upscale); they differ only in the configured dimensions. +// fit the source within maxW×maxH preserving the aspect ratio (no crop, no +// upscale); they differ only in the configured dimensions. // // Resolution order: // 1. Return cached JPEG if present. -// 2. Decode as still image (JPEG/PNG/GIF via imaging). -// 3. Extract a frame with ffmpeg (video files). -// 4. Solid-colour placeholder (archives, unrecognised formats, etc.). +// 2. vipsthumbnail (shrink-on-load; the primary still-image path). +// 3. Pure-Go decode + imaging.Fit (fallback when vips is absent). +// 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) { // Fast path: cache hit. 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 } - // 1. Try still-image decode (JPEG/PNG/GIF), rejecting decompression bombs. - // 2. Try video frame extraction via ffmpeg. - // 3. Fall back to placeholder. + // Primary path: vipsthumbnail. It shrinks on load (e.g. JPEG DCT scaling), so + // even a 200+ Mpx photo is thumbnailed in a fraction of the memory and CPU of a + // 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 if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil { 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)) } +// 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. // 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 diff --git a/backend/internal/storage/disk_test.go b/backend/internal/storage/disk_test.go index 8ff02c4..ad257a8 100644 --- a/backend/internal/storage/disk_test.go +++ b/backend/internal/storage/disk_test.go @@ -97,3 +97,85 @@ func TestThumbnailGeneratesAndCaches(t *testing.T) { } 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") + } +}