Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88e07f0723 | |||
| 384386a34e |
+4
-3
@@ -92,9 +92,10 @@ THUMB_WIDTH=160
|
|||||||
THUMB_HEIGHT=160
|
THUMB_HEIGHT=160
|
||||||
PREVIEW_WIDTH=1920
|
PREVIEW_WIDTH=1920
|
||||||
PREVIEW_HEIGHT=1080
|
PREVIEW_HEIGHT=1080
|
||||||
# Largest source image (in pixels, width×height) decoded to build a thumbnail or
|
# Pixel cap (width×height) for the pure-Go fallback decoder, used only when
|
||||||
# preview; bigger images get a placeholder. Bounds a decompression bomb and the
|
# vipsthumbnail is NOT installed; larger images then get a placeholder. With vips
|
||||||
# per-image RAM cost. Default ~300 Mpx (e.g. 13000×17000).
|
# present (the default image) thumbnails shrink on load, so this limit — and its
|
||||||
|
# RAM cost — don't apply. Also bounds a decompression bomb. Default ~300 Mpx.
|
||||||
THUMB_MAX_PIXELS=300000000
|
THUMB_MAX_PIXELS=300000000
|
||||||
# How many thumbnails/previews may be generated at once. Each resize already uses
|
# How many thumbnails/previews may be generated at once. Each resize already uses
|
||||||
# every core, so a burst of large images otherwise pegs the CPU and RAM. 0 = auto
|
# every core, so a burst of large images otherwise pegs the CPU and RAM. 0 = auto
|
||||||
|
|||||||
+10
-8
@@ -41,23 +41,25 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
|
||||||
# CGO is disabled: image processing is pure Go (disintegration/imaging) and
|
# CGO is disabled: the binary shells out to external tools at runtime
|
||||||
# video thumbnails shell out to the ffmpeg binary at runtime, so the resulting
|
# (vipsthumbnail for image thumbnails, ffmpeg for video frames, exiftool for
|
||||||
# binary is fully static and portable across base images.
|
# metadata) and falls back to pure-Go image processing (disintegration/imaging)
|
||||||
|
# when vips is absent, so it stays fully static and portable across base images.
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Stage 3 — minimal runtime
|
# Stage 3 — minimal runtime
|
||||||
#
|
#
|
||||||
# Alpine (not distroless/scratch) because video thumbnailing invokes ffmpeg and
|
# Alpine (not distroless/scratch) because thumbnailing and metadata extraction
|
||||||
# metadata extraction invokes exiftool as external processes; both must be
|
# invoke external processes (vipsthumbnail, ffmpeg, exiftool) that must be present
|
||||||
# present on the runtime image.
|
# on the runtime image.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM alpine:3.21 AS runtime
|
FROM alpine:3.21 AS runtime
|
||||||
|
|
||||||
# ffmpeg: video frame extraction. exiftool: rich image/video/audio metadata.
|
# vips-tools: fast, low-memory image thumbnails (shrink-on-load, so multi-hundred-
|
||||||
|
# Mpx photos cost little). ffmpeg: video frame extraction. exiftool: rich metadata.
|
||||||
# ca-certificates/tzdata: TLS + time zones.
|
# ca-certificates/tzdata: TLS + time zones.
|
||||||
RUN apk add --no-cache ffmpeg exiftool ca-certificates tzdata
|
RUN apk add --no-cache vips-tools ffmpeg exiftool ca-certificates tzdata
|
||||||
|
|
||||||
# Run as an unprivileged user.
|
# Run as an unprivileged user.
|
||||||
RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata
|
RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user