Updated FFmpeg drawtext filter to use DejaVu Sans Mono for metadata text on contact sheets. This matches the monospace font style used throughout the VideoTools UI. DejaVu Sans Mono is widely available across Linux, macOS, and Windows, ensuring consistent appearance across platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
452 lines
13 KiB
Go
452 lines
13 KiB
Go
package thumbnail
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Config contains configuration for thumbnail generation
|
|
type Config struct {
|
|
VideoPath string
|
|
OutputDir string
|
|
Count int // Number of thumbnails to generate
|
|
Interval float64 // Interval in seconds between thumbnails (alternative to Count)
|
|
Width int // Thumbnail width (0 = auto based on height)
|
|
Height int // Thumbnail height (0 = auto based on width)
|
|
Quality int // JPEG quality 1-100 (0 = PNG lossless)
|
|
Format string // "png" or "jpg"
|
|
StartOffset float64 // Start generating from this timestamp
|
|
EndOffset float64 // Stop generating before this timestamp
|
|
ContactSheet bool // Generate a single contact sheet instead of individual files
|
|
Columns int // Contact sheet columns (if ContactSheet=true)
|
|
Rows int // Contact sheet rows (if ContactSheet=true)
|
|
ShowTimestamp bool // Overlay timestamp on thumbnails
|
|
ShowMetadata bool // Show metadata header on contact sheet
|
|
}
|
|
|
|
// Generator creates thumbnails from videos
|
|
type Generator struct {
|
|
FFmpegPath string
|
|
}
|
|
|
|
// NewGenerator creates a new thumbnail generator
|
|
func NewGenerator(ffmpegPath string) *Generator {
|
|
return &Generator{
|
|
FFmpegPath: ffmpegPath,
|
|
}
|
|
}
|
|
|
|
// Thumbnail represents a generated thumbnail
|
|
type Thumbnail struct {
|
|
Path string
|
|
Timestamp float64
|
|
Width int
|
|
Height int
|
|
Size int64
|
|
}
|
|
|
|
// GenerateResult contains the results of thumbnail generation
|
|
type GenerateResult struct {
|
|
Thumbnails []Thumbnail
|
|
ContactSheet string // Path to contact sheet if generated
|
|
TotalDuration float64
|
|
VideoWidth int
|
|
VideoHeight int
|
|
VideoFPS float64
|
|
VideoCodec string
|
|
AudioCodec string
|
|
FileSize int64
|
|
Error string
|
|
}
|
|
|
|
// Generate creates thumbnails based on the provided configuration
|
|
func (g *Generator) Generate(ctx context.Context, config Config) (*GenerateResult, error) {
|
|
result := &GenerateResult{}
|
|
|
|
// Validate config
|
|
if config.VideoPath == "" {
|
|
return nil, fmt.Errorf("video path is required")
|
|
}
|
|
if config.OutputDir == "" {
|
|
return nil, fmt.Errorf("output directory is required")
|
|
}
|
|
|
|
// Create output directory if it doesn't exist
|
|
if err := os.MkdirAll(config.OutputDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
if config.Count == 0 && config.Interval == 0 {
|
|
config.Count = 9 // Default to 9 thumbnails (3x3 grid)
|
|
}
|
|
if config.Format == "" {
|
|
config.Format = "jpg"
|
|
}
|
|
if config.Quality == 0 && config.Format == "jpg" {
|
|
config.Quality = 85
|
|
}
|
|
if config.ContactSheet {
|
|
if config.Columns == 0 {
|
|
config.Columns = 3
|
|
}
|
|
if config.Rows == 0 {
|
|
config.Rows = 3
|
|
}
|
|
}
|
|
|
|
// Get video duration and dimensions
|
|
duration, width, height, err := g.getVideoInfo(ctx, config.VideoPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get video info: %w", err)
|
|
}
|
|
result.TotalDuration = duration
|
|
result.VideoWidth = width
|
|
result.VideoHeight = height
|
|
|
|
// Calculate thumbnail dimensions
|
|
thumbWidth, thumbHeight := g.calculateDimensions(width, height, config.Width, config.Height)
|
|
|
|
if config.ContactSheet {
|
|
// Generate contact sheet
|
|
contactSheetPath, err := g.generateContactSheet(ctx, config, duration, thumbWidth, thumbHeight)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, err
|
|
}
|
|
result.ContactSheet = contactSheetPath
|
|
|
|
// Get file size
|
|
if fi, err := os.Stat(contactSheetPath); err == nil {
|
|
result.Thumbnails = []Thumbnail{{
|
|
Path: contactSheetPath,
|
|
Timestamp: 0,
|
|
Width: thumbWidth * config.Columns,
|
|
Height: thumbHeight * config.Rows,
|
|
Size: fi.Size(),
|
|
}}
|
|
}
|
|
} else {
|
|
// Generate individual thumbnails
|
|
thumbnails, err := g.generateIndividual(ctx, config, duration, thumbWidth, thumbHeight)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, err
|
|
}
|
|
result.Thumbnails = thumbnails
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// getVideoInfo retrieves duration and dimensions from a video file
|
|
func (g *Generator) getVideoInfo(ctx context.Context, videoPath string) (duration float64, width, height int, err error) {
|
|
// Use ffprobe to get video information
|
|
cmd := exec.CommandContext(ctx, "ffprobe",
|
|
"-v", "error",
|
|
"-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height,duration",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1",
|
|
videoPath,
|
|
)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("ffprobe failed: %w", err)
|
|
}
|
|
|
|
// Parse output
|
|
var w, h int
|
|
var d float64
|
|
_, _ = fmt.Sscanf(string(output), "width=%d\nheight=%d\nduration=%f", &w, &h, &d)
|
|
|
|
// If stream duration not available, try format duration
|
|
if d == 0 {
|
|
_, _ = fmt.Sscanf(string(output), "width=%d\nheight=%d\nwidth=%*d\nheight=%*d\nduration=%f", &w, &h, &d)
|
|
}
|
|
|
|
if w == 0 || h == 0 || d == 0 {
|
|
return 0, 0, 0, fmt.Errorf("failed to parse video info")
|
|
}
|
|
|
|
return d, w, h, nil
|
|
}
|
|
|
|
// calculateDimensions determines thumbnail dimensions maintaining aspect ratio
|
|
func (g *Generator) calculateDimensions(videoWidth, videoHeight, targetWidth, targetHeight int) (width, height int) {
|
|
if targetWidth == 0 && targetHeight == 0 {
|
|
// Default to 320 width
|
|
targetWidth = 320
|
|
}
|
|
|
|
aspectRatio := float64(videoWidth) / float64(videoHeight)
|
|
|
|
if targetWidth > 0 && targetHeight == 0 {
|
|
// Calculate height from width
|
|
width = targetWidth
|
|
height = int(float64(width) / aspectRatio)
|
|
} else if targetHeight > 0 && targetWidth == 0 {
|
|
// Calculate width from height
|
|
height = targetHeight
|
|
width = int(float64(height) * aspectRatio)
|
|
} else {
|
|
// Both specified, use as-is
|
|
width = targetWidth
|
|
height = targetHeight
|
|
}
|
|
|
|
return width, height
|
|
}
|
|
|
|
// generateIndividual creates individual thumbnail files
|
|
func (g *Generator) generateIndividual(ctx context.Context, config Config, duration float64, thumbWidth, thumbHeight int) ([]Thumbnail, error) {
|
|
var thumbnails []Thumbnail
|
|
|
|
// Calculate timestamps
|
|
timestamps := g.calculateTimestamps(config, duration)
|
|
|
|
// Generate each thumbnail
|
|
for i, ts := range timestamps {
|
|
outputPath := filepath.Join(config.OutputDir, fmt.Sprintf("thumb_%04d.%s", i+1, config.Format))
|
|
|
|
// Build FFmpeg command
|
|
args := []string{
|
|
"-ss", fmt.Sprintf("%.2f", ts),
|
|
"-i", config.VideoPath,
|
|
"-vf", fmt.Sprintf("scale=%d:%d", thumbWidth, thumbHeight),
|
|
"-frames:v", "1",
|
|
"-y",
|
|
}
|
|
|
|
// Add quality settings
|
|
if config.Format == "jpg" {
|
|
args = append(args, "-q:v", fmt.Sprintf("%d", 31-(config.Quality*30/100)))
|
|
}
|
|
|
|
// Add timestamp overlay if requested
|
|
if config.ShowTimestamp {
|
|
hours := int(ts) / 3600
|
|
minutes := (int(ts) % 3600) / 60
|
|
seconds := int(ts) % 60
|
|
timeStr := fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
|
|
|
drawTextFilter := fmt.Sprintf("scale=%d:%d,drawtext=text='%s':fontcolor=white:fontsize=20:box=1:boxcolor=black@0.5:boxborderw=5:x=(w-text_w)/2:y=h-th-10",
|
|
thumbWidth, thumbHeight, timeStr)
|
|
|
|
// Replace scale filter with combined filter
|
|
for j, arg := range args {
|
|
if arg == "-vf" && j+1 < len(args) {
|
|
args[j+1] = drawTextFilter
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
args = append(args, outputPath)
|
|
|
|
cmd := exec.CommandContext(ctx, g.FFmpegPath, args...)
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("failed to generate thumbnail %d: %w", i+1, err)
|
|
}
|
|
|
|
// Get file info
|
|
fi, err := os.Stat(outputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat thumbnail %d: %w", i+1, err)
|
|
}
|
|
|
|
thumbnails = append(thumbnails, Thumbnail{
|
|
Path: outputPath,
|
|
Timestamp: ts,
|
|
Width: thumbWidth,
|
|
Height: thumbHeight,
|
|
Size: fi.Size(),
|
|
})
|
|
}
|
|
|
|
return thumbnails, nil
|
|
}
|
|
|
|
// generateContactSheet creates a single contact sheet with all thumbnails
|
|
func (g *Generator) generateContactSheet(ctx context.Context, config Config, duration float64, thumbWidth, thumbHeight int) (string, error) {
|
|
totalThumbs := config.Columns * config.Rows
|
|
if config.Count > 0 && config.Count < totalThumbs {
|
|
totalThumbs = config.Count
|
|
}
|
|
|
|
// Calculate timestamps
|
|
tempConfig := config
|
|
tempConfig.Count = totalThumbs
|
|
tempConfig.Interval = 0
|
|
timestamps := g.calculateTimestamps(tempConfig, duration)
|
|
|
|
// Build select filter for timestamps
|
|
selectFilter := "select='"
|
|
for i, ts := range timestamps {
|
|
if i > 0 {
|
|
selectFilter += "+"
|
|
}
|
|
selectFilter += fmt.Sprintf("eq(n\\,%d)", int(ts*30)) // Assuming 30fps, should calculate actual fps
|
|
}
|
|
selectFilter += "'"
|
|
|
|
outputPath := filepath.Join(config.OutputDir, fmt.Sprintf("contact_sheet.%s", config.Format))
|
|
|
|
// Build tile filter
|
|
tileFilter := fmt.Sprintf("scale=%d:%d,tile=%dx%d", thumbWidth, thumbHeight, config.Columns, config.Rows)
|
|
|
|
// Build video filter
|
|
var vfilter string
|
|
if config.ShowMetadata {
|
|
// Add metadata header to contact sheet
|
|
vfilter = g.buildMetadataFilter(config, duration, thumbWidth, thumbHeight, selectFilter, tileFilter)
|
|
} else {
|
|
vfilter = fmt.Sprintf("%s,%s", selectFilter, tileFilter)
|
|
}
|
|
|
|
// Build FFmpeg command
|
|
args := []string{
|
|
"-i", config.VideoPath,
|
|
"-vf", vfilter,
|
|
"-frames:v", "1",
|
|
"-y",
|
|
}
|
|
|
|
if config.Format == "jpg" {
|
|
args = append(args, "-q:v", fmt.Sprintf("%d", 31-(config.Quality*30/100)))
|
|
}
|
|
|
|
args = append(args, outputPath)
|
|
|
|
cmd := exec.CommandContext(ctx, g.FFmpegPath, args...)
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("failed to generate contact sheet: %w", err)
|
|
}
|
|
|
|
return outputPath, nil
|
|
}
|
|
|
|
// buildMetadataFilter creates a filter that adds metadata header to contact sheet
|
|
func (g *Generator) buildMetadataFilter(config Config, duration float64, thumbWidth, thumbHeight int, selectFilter, tileFilter string) string {
|
|
// Get file info
|
|
fileInfo, _ := os.Stat(config.VideoPath)
|
|
fileSize := fileInfo.Size()
|
|
fileSizeMB := float64(fileSize) / (1024 * 1024)
|
|
|
|
// Get video info (we already have duration, just need dimensions)
|
|
_, videoWidth, videoHeight, _ := g.getVideoInfo(context.Background(), config.VideoPath)
|
|
|
|
// Format duration as HH:MM:SS
|
|
hours := int(duration) / 3600
|
|
minutes := (int(duration) % 3600) / 60
|
|
seconds := int(duration) % 60
|
|
durationStr := fmt.Sprintf("%02d\\:%02d\\:%02d", hours, minutes, seconds)
|
|
|
|
// Get just the filename without path
|
|
filename := filepath.Base(config.VideoPath)
|
|
|
|
// Calculate sheet dimensions
|
|
sheetWidth := thumbWidth * config.Columns
|
|
sheetHeight := thumbHeight * config.Rows
|
|
headerHeight := 80
|
|
|
|
// Build metadata text lines
|
|
// Line 1: Filename and file size
|
|
line1 := fmt.Sprintf("%s (%.1f MB)", filename, fileSizeMB)
|
|
// Line 2: Resolution, FPS, Duration
|
|
line2 := fmt.Sprintf("%dx%d | Duration\\: %s", videoWidth, videoHeight, durationStr)
|
|
|
|
// Create filter that:
|
|
// 1. Generates contact sheet from selected frames
|
|
// 2. Creates a blank header area
|
|
// 3. Draws metadata text on header (using monospace font)
|
|
// 4. Stacks header on top of contact sheet
|
|
filter := fmt.Sprintf(
|
|
"%s,%s,pad=%d:%d:0:%d:black,"+
|
|
"drawtext=text='%s':fontcolor=white:fontsize=14:font='DejaVu Sans Mono':x=10:y=10,"+
|
|
"drawtext=text='%s':fontcolor=white:fontsize=12:font='DejaVu Sans Mono':x=10:y=35",
|
|
selectFilter,
|
|
tileFilter,
|
|
sheetWidth,
|
|
sheetHeight+headerHeight,
|
|
headerHeight,
|
|
line1,
|
|
line2,
|
|
)
|
|
|
|
return filter
|
|
}
|
|
|
|
// calculateTimestamps generates timestamps for thumbnail extraction
|
|
func (g *Generator) calculateTimestamps(config Config, duration float64) []float64 {
|
|
var timestamps []float64
|
|
|
|
startTime := config.StartOffset
|
|
endTime := duration - config.EndOffset
|
|
if endTime <= startTime {
|
|
endTime = duration
|
|
}
|
|
|
|
availableDuration := endTime - startTime
|
|
|
|
if config.Interval > 0 {
|
|
// Use interval mode
|
|
for ts := startTime; ts < endTime; ts += config.Interval {
|
|
timestamps = append(timestamps, ts)
|
|
}
|
|
} else {
|
|
// Use count mode
|
|
if config.Count <= 1 {
|
|
// Single thumbnail at midpoint
|
|
timestamps = append(timestamps, startTime+availableDuration/2)
|
|
} else {
|
|
// Distribute evenly
|
|
step := availableDuration / float64(config.Count+1)
|
|
for i := 1; i <= config.Count; i++ {
|
|
ts := startTime + (step * float64(i))
|
|
timestamps = append(timestamps, ts)
|
|
}
|
|
}
|
|
}
|
|
|
|
return timestamps
|
|
}
|
|
|
|
// ExtractFrame extracts a single frame at a specific timestamp
|
|
func (g *Generator) ExtractFrame(ctx context.Context, videoPath string, timestamp float64, outputPath string, width, height int) error {
|
|
args := []string{
|
|
"-ss", fmt.Sprintf("%.2f", timestamp),
|
|
"-i", videoPath,
|
|
"-frames:v", "1",
|
|
"-y",
|
|
}
|
|
|
|
if width > 0 || height > 0 {
|
|
if width == 0 {
|
|
width = -1 // Auto
|
|
}
|
|
if height == 0 {
|
|
height = -1 // Auto
|
|
}
|
|
args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", width, height))
|
|
}
|
|
|
|
args = append(args, outputPath)
|
|
|
|
cmd := exec.CommandContext(ctx, g.FFmpegPath, args...)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to extract frame: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupThumbnails removes all generated thumbnails
|
|
func CleanupThumbnails(outputDir string) error {
|
|
return os.RemoveAll(outputDir)
|
|
}
|