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:
soup 2026-01-17 01:09:23 -05:00
commit cdcc5b5293
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
45 changed files with 4634 additions and 0 deletions

151
internal/video/process.go Normal file
View 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
}