package media import ( "bytes" "context" "fmt" "image" "image/jpeg" _ "image/png" "io" "sync" "github.com/example/sndit/backend/internal/storage" "github.com/google/uuid" ) type Processor struct { repository *Repository storage storage.Storage jobs chan uuid.UUID stop chan struct{} waitGroup sync.WaitGroup } func NewProcessor(repository *Repository, objectStorage storage.Storage, workers int) *Processor { if workers < 1 { workers = 1 } processor := &Processor{ repository: repository, storage: objectStorage, jobs: make(chan uuid.UUID, 256), stop: make(chan struct{}), } for index := 0; index < workers; index++ { processor.waitGroup.Add(1) go processor.worker() } return processor } func (p *Processor) Enqueue(mediaID uuid.UUID) { select { case p.jobs <- mediaID: case <-p.stop: } } func (p *Processor) Close() { close(p.stop) p.waitGroup.Wait() } func (p *Processor) worker() { defer p.waitGroup.Done() for { select { case mediaID := <-p.jobs: p.process(mediaID) case <-p.stop: return } } } func (p *Processor) process(mediaID uuid.UUID) { ctx := context.Background() item, err := p.repository.GetByID(ctx, mediaID) if err != nil { return } if item.ExternalURL != "" || !IsImage(item) { _ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height) return } object, err := p.storage.Get(ctx, item.StorageKey) if err != nil { _ = p.repository.MarkFailed(ctx, mediaID, 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) return } width := decoded.Bounds().Dx() 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()) return } thumbnail, err := encodeJPEG(resize(decoded, 640), 84) if err != nil { _ = p.repository.MarkFailed(ctx, mediaID, 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()) 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()) return } if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil { return } } func variantKey(item Record, filename string) string { return fmt.Sprintf("galleries/%s/%s/%s", item.GalleryID, item.ID, filename) } func resize(source image.Image, maxSide int) image.Image { bounds := source.Bounds() width, height := bounds.Dx(), bounds.Dy() if width <= maxSide && height <= maxSide { return source } scale := float64(maxSide) / float64(width) if height > width { scale = float64(maxSide) / float64(height) } newWidth := int(float64(width) * scale) newHeight := int(float64(height) * scale) destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) for y := 0; y < newHeight; y++ { for x := 0; x < newWidth; x++ { sourceX := bounds.Min.X + x*width/newWidth sourceY := bounds.Min.Y + y*height/newHeight destination.Set(x, y, source.At(sourceX, sourceY)) } } return destination } func encodeJPEG(source image.Image, quality int) ([]byte, error) { var output bytes.Buffer if err := jpeg.Encode(&output, source, &jpeg.Options{Quality: quality}); err != nil { return nil, fmt.Errorf("encode preview: %w", err) } return output.Bytes(), nil }