diff --git a/backend/Dockerfile b/backend/Dockerfile index af59bac..9ae2881 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,7 +8,7 @@ RUN go build -o /usr/bin/migrate ./cmd/migrate RUN go build -o /usr/bin/seed ./cmd/seed FROM alpine:3.21 -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates ffmpeg COPY --from=builder /usr/bin/server /usr/bin/migrate /usr/bin/seed /usr/bin/ EXPOSE 8080 CMD ["server"] \ No newline at end of file diff --git a/backend/internal/media/processor.go b/backend/internal/media/processor.go index ce2b19f..6ea34c5 100644 --- a/backend/internal/media/processor.go +++ b/backend/internal/media/processor.go @@ -5,9 +5,15 @@ import ( "context" "fmt" "image" + "image/color" + "image/draw" "image/jpeg" _ "image/png" "io" + "log" + "os" + "os/exec" + "path/filepath" "sync" "github.com/example/sndit/backend/internal/storage" @@ -70,23 +76,31 @@ func (p *Processor) process(mediaID uuid.UUID) { return } - if item.ExternalURL != "" || !IsImage(item) { + if item.ExternalURL != "" { _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height) return } + if IsImage(item) { + p.processImage(ctx, item) + } else if IsVideo(item) { + p.processVideo(ctx, item) + } else { + _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height) + } +} + +func (p *Processor) processImage(ctx context.Context, item Record) { object, err := p.storage.Get(ctx, item.StorageKey) if err != nil { - _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + _ = p.repository.MarkFailed(ctx, item.ID, err.Error()) return } defer object.Close() decoded, _, err := image.Decode(io.LimitReader(object, 100<<20)) if err != nil { - // Formats without a stdlib decoder, such as HEIC, remain usable through - // the original object until a dedicated processing service is added. - _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0) + _ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0) return } @@ -94,27 +108,132 @@ func (p *Processor) process(mediaID uuid.UUID) { height := decoded.Bounds().Dy() previewKey := variantKey(item, "preview.jpg") thumbnailKey := variantKey(item, "thumbnail.jpg") + preview, err := encodeJPEG(resize(decoded, 2400), 88) if err != nil { - _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + _ = p.repository.MarkFailed(ctx, item.ID, err.Error()) return } thumbnail, err := encodeJPEG(resize(decoded, 640), 84) if err != nil { - _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + _ = p.repository.MarkFailed(ctx, item.ID, err.Error()) return } if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil { - _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + _ = p.repository.MarkFailed(ctx, item.ID, err.Error()) return } if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil { - _ = p.repository.MarkFailed(ctx, mediaID, err.Error()) + _ = p.repository.MarkFailed(ctx, item.ID, err.Error()) return } - if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil { + _ = p.repository.MarkReady(ctx, item.ID, previewKey, thumbnailKey, width, height) +} + +func (p *Processor) processVideo(ctx context.Context, item Record) { + thumbnailKey := variantKey(item, "thumbnail.jpg") + previewKey := variantKey(item, "preview.mp4") + + if err := p.generateVideoThumbnail(ctx, item, thumbnailKey); err != nil { + p.log("video thumbnail failed for %s: %s — using fallback", item.ID, err) + p.generateFallbackThumbnail(ctx, item, thumbnailKey) + } + if err := p.generateVideoPreview(ctx, item, previewKey); err != nil { + p.log("video preview failed for %s: %s — using original", item.ID, err) + _ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, thumbnailKey, 0, 0) return } + _ = p.repository.MarkReady(ctx, item.ID, previewKey, thumbnailKey, 0, 0) +} + +func (p *Processor) generateVideoThumbnail(ctx context.Context, item Record, key string) error { + tmp := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-%s", uuid.New())) + defer os.Remove(tmp) + + if err := p.downloadToFile(ctx, item.StorageKey, tmp); err != nil { + return err + } + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "ffmpeg", + "-ss", "0", + "-i", tmp, + "-vframes", "1", + "-q:v", "6", + "-f", "image2pipe", + "-c:v", "mjpeg", + "pipe:1", + ) + cmd.Stderr = &stderr + + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("ffmpeg thumbnail: %w, stderr: %s", err, stderr.String()) + } + + return p.storage.Put(ctx, key, bytes.NewReader(output), int64(len(output)), "image/jpeg") +} + +func (p *Processor) generateVideoPreview(ctx context.Context, item Record, key string) error { + tmpInput := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-in-%s", uuid.New())) + tmpOutput := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-out-%s.mp4", uuid.New())) + defer os.Remove(tmpInput) + defer os.Remove(tmpOutput) + + if err := p.downloadToFile(ctx, item.StorageKey, tmpInput); err != nil { + return err + } + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "ffmpeg", + "-i", tmpInput, + "-vf", "scale='min(720,iw)':-1:force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1:black", + "-c:v", "libx264", + "-preset", "fast", + "-crf", "28", + "-movflags", "+faststart", + "-c:a", "aac", + "-b:a", "64k", + "-y", tmpOutput, + ) + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("ffmpeg preview: %w, stderr: %s", err, stderr.String()) + } + + data, err := os.ReadFile(tmpOutput) + if err != nil { + return fmt.Errorf("read output file: %w", err) + } + + return p.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "video/mp4") +} + +func (p *Processor) downloadToFile(ctx context.Context, storageKey, tmp string) error { + object, err := p.storage.Get(ctx, storageKey) + if err != nil { + return fmt.Errorf("get original: %w", err) + } + defer object.Close() + + f, err := os.Create(tmp) + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + if _, err := io.Copy(f, object); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("copy to temp file: %w", err) + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("sync temp file: %w", err) + } + f.Close() + return nil } func variantKey(item Record, filename string) string { @@ -152,3 +271,26 @@ func encodeJPEG(source image.Image, quality int) ([]byte, error) { } return output.Bytes(), nil } + +func (p *Processor) log(format string, args ...any) { + log.Printf("[media-processor] "+format, args...) +} + +func (p *Processor) generateFallbackThumbnail(ctx context.Context, item Record, key string) { + img := image.NewRGBA(image.Rect(0, 0, 640, 480)) + bg := color.RGBA{20, 20, 20, 255} + draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) + + data, err := encodeJPEG(img, 60) + if err != nil { + p.log("fallback thumbnail encode failed: %s", err) + _ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0) + return + } + if err := p.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil { + p.log("fallback thumbnail upload failed: %s", err) + _ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0) + return + } + _ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, key, 0, 0) +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2220f92..20d297d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import GalleryPreviewPage from './pages/GalleryPreviewPage'; import LoginPage from './pages/LoginPage'; import PublicGalleryPage from './pages/PublicGalleryPage'; import RegisterPage from './pages/RegisterPage'; +import RootPage from './pages/RootPage'; import DashboardPage from './pages/dashboard/DashboardPage'; import GalleriesPage from './pages/dashboard/GalleriesPage'; import GalleryEditorPage from './pages/dashboard/GalleryEditorPage'; @@ -19,6 +20,7 @@ export default function App() { + } /> } /> } /> } /> diff --git a/frontend/src/components/app/AuthLayout.tsx b/frontend/src/components/app/AuthLayout.tsx index 0e5d7e3..5076bb4 100644 --- a/frontend/src/components/app/AuthLayout.tsx +++ b/frontend/src/components/app/AuthLayout.tsx @@ -1,17 +1,20 @@ import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; export function AuthLayout({ children }: { children: ReactNode }) { return (