package video import ( "bytes" "context" "fmt" "io" "os" "os/exec" "path/filepath" ) // ExtractThumbnail extracts a thumbnail from a video file. // Returns the thumbnail image data as JPEG. func ExtractThumbnail(ctx context.Context, videoData []byte) ([]byte, error) { // Write video to temp file tmpDir, err := os.MkdirTemp("", "lookbook-video-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) inputPath := filepath.Join(tmpDir, "input") outputPath := filepath.Join(tmpDir, "thumbnail.jpg") if err := os.WriteFile(inputPath, videoData, 0600); err != nil { return nil, fmt.Errorf("write temp video: %w", err) } // Extract thumbnail at 1 second mark cmd := exec.CommandContext(ctx, "ffmpeg", "-i", inputPath, "-ss", "00:00:01", "-vframes", "1", "-vf", "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease", "-q:v", "2", "-y", outputPath, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg thumbnail: %w: %s", err, stderr.String()) } thumbnail, err := os.ReadFile(outputPath) if err != nil { return nil, fmt.Errorf("read thumbnail: %w", err) } return thumbnail, nil } // TranscodeToMP4 transcodes a video to H.264 MP4 format. // Returns the transcoded video data. func TranscodeToMP4(ctx context.Context, videoData []byte, contentType string) ([]byte, error) { // If already MP4 with H.264, we might skip transcoding // For simplicity, we always transcode to ensure compatibility tmpDir, err := os.MkdirTemp("", "lookbook-video-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) inputPath := filepath.Join(tmpDir, "input") outputPath := filepath.Join(tmpDir, "output.mp4") if err := os.WriteFile(inputPath, videoData, 0600); err != nil { return nil, fmt.Errorf("write temp video: %w", err) } // Transcode to H.264 MP4 cmd := exec.CommandContext(ctx, "ffmpeg", "-i", inputPath, "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", outputPath, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg transcode: %w: %s", err, stderr.String()) } output, err := os.ReadFile(outputPath) if err != nil { return nil, fmt.Errorf("read transcoded: %w", err) } return output, nil } // ProcessVideo processes an uploaded video: transcodes to MP4 and extracts thumbnail. // Returns (transcodedData, thumbnailData, error). func ProcessVideo(ctx context.Context, videoData []byte, contentType string) ([]byte, []byte, error) { // Extract thumbnail first (from original, often has better quality) thumbnail, err := ExtractThumbnail(ctx, videoData) if err != nil { return nil, nil, fmt.Errorf("extract thumbnail: %w", err) } // Transcode to MP4 transcoded, err := TranscodeToMP4(ctx, videoData, contentType) if err != nil { return nil, nil, fmt.Errorf("transcode: %w", err) } return transcoded, thumbnail, nil } // IsVideo checks if a content type is a video type. func IsVideo(contentType string) bool { switch contentType { case "video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/mpeg", "video/ogg", "video/3gpp": return true } return false } // IsImage checks if a content type is an image type. func IsImage(contentType string) bool { switch contentType { case "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif": return true } return false } // ReadUpload reads an uploaded file up to maxSize bytes. func ReadUpload(r io.Reader, maxSize int64) ([]byte, error) { limited := io.LimitReader(r, maxSize+1) data, err := io.ReadAll(limited) if err != nil { return nil, err } if int64(len(data)) > maxSize { return nil, fmt.Errorf("file too large (max %d bytes)", maxSize) } return data, nil }