update style
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user