Initial commit: Lookbook personal collection app
Pinterest-like app for saving images, videos, quotes, and embeds. Features: - Go backend with PostgreSQL, SSR templates - Console-based admin auth (login/logout via browser console) - Item types: images, videos (ffmpeg transcoding), quotes, embeds - Media stored as BLOBs in PostgreSQL - OpenGraph metadata extraction for links - Embed detection for YouTube, Vimeo, Twitter/X - Masonry grid layout, item detail pages - Tag system with filtering - Refresh metadata endpoint with change warnings - Replace media endpoint for updating item images/videos
This commit is contained in:
commit
cdcc5b5293
45 changed files with 4634 additions and 0 deletions
151
internal/video/process.go
Normal file
151
internal/video/process.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue