feat(backend): seed MIME types and support all image/video formats

007_seed_data.sql: insert 10 MIME types (4 image, 6 video) with their
canonical extensions into core.mime_types.

disk.go: register golang.org/x/image/webp decoder so imaging.Open
handles WebP still images. Videos (mp4, mov, avi, webm, 3gp, m4v)
continue to go through the ffmpeg frame-extraction path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Masahiko AMANO 2026-04-04 18:21:27 +03:00
parent fae87ad05c
commit 0ae8b81a0b
2 changed files with 59 additions and 12 deletions

View File

@ -10,8 +10,10 @@ import (
"image/jpeg"
_ "image/gif" // register GIF decoder
_ "image/png" // register PNG decoder
_ "golang.org/x/image/webp" // register WebP decoder
"io"
"os"
"os/exec"
"path/filepath"
"github.com/disintegration/imaging"
@ -108,16 +110,16 @@ func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
// Thumbnail returns a JPEG that fits within the configured max width×height
// (never upscaled, never cropped). Generated on first call and cached.
// Non-image source files receive a solid-colour placeholder.
func (s *DiskStorage) Thumbnail(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
}
// Preview returns a JPEG that fits within the configured max width×height
// (never upscaled, never cropped). Generated on first call and cached.
// Non-image source files receive a solid-colour placeholder.
func (s *DiskStorage) Preview(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
}
// ---------------------------------------------------------------------------
@ -126,7 +128,13 @@ func (s *DiskStorage) Preview(_ context.Context, id uuid.UUID) (io.ReadCloser, e
// serveGenerated is the shared implementation for Thumbnail and Preview.
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping.
func (s *DiskStorage) serveGenerated(id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
//
// 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.).
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 {
return f, nil
@ -141,12 +149,14 @@ func (s *DiskStorage) serveGenerated(id uuid.UUID, cachePath string, maxW, maxH
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
}
// Attempt to decode as an image. imaging.Open handles JPEG, PNG, and GIF
// (decoders registered via blank imports). Any non-decodable file (video,
// archive, …) silently produces a placeholder.
// 1. Try still-image decode (JPEG/PNG/GIF).
// 2. Try video frame extraction via ffmpeg.
// 3. Fall back to placeholder.
var img image.Image
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
} else {
img = placeholder(maxW, maxH)
}
@ -196,6 +206,30 @@ func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
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
// ffmpeg and the first available frame is returned instead.
// Returns an error if ffmpeg is not installed or produces no output.
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
var out bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg",
"-ss", "1", // fast input seek; ignored gracefully on short files
"-i", srcPath,
"-vframes", "1",
"-f", "image2",
"-vcodec", "png",
"pipe:1",
)
cmd.Stdout = &out
cmd.Stderr = io.Discard // suppress ffmpeg progress output
if err := cmd.Run(); err != nil || out.Len() == 0 {
return nil, fmt.Errorf("ffmpeg frame extract: %w", err)
}
return imaging.Decode(&out)
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------

View File

@ -1,5 +1,17 @@
-- +goose Up
INSERT INTO core.mime_types (name, extension) VALUES
('image/jpeg', 'jpg'),
('image/png', 'png'),
('image/gif', 'gif'),
('image/webp', 'webp'),
('video/mp4', 'mp4'),
('video/quicktime', 'mov'),
('video/x-msvideo', 'avi'),
('video/webm', 'webm'),
('video/3gpp', '3gp'),
('video/x-m4v', 'm4v');
INSERT INTO core.object_types (name) VALUES
('file'), ('tag'), ('category'), ('pool');
@ -34,3 +46,4 @@ INSERT INTO core.users (name, password, is_admin, can_create) VALUES
DELETE FROM core.users WHERE name = 'admin';
DELETE FROM activity.action_types;
DELETE FROM core.object_types;
DELETE FROM core.mime_types;