Add metadata header to thumbnail contact sheets
Implemented metadata header rendering on contact sheets showing: - Filename and file size - Video resolution and duration Uses FFmpeg pad and drawtext filters to create an 80px header area with white text on black background.
This commit is contained in:
parent
6448e04e90
commit
4f685d720d
|
|
@ -299,15 +299,19 @@ func (g *Generator) generateContactSheet(ctx context.Context, config Config, dur
|
||||||
// Build tile filter
|
// Build tile filter
|
||||||
tileFilter := fmt.Sprintf("scale=%d:%d,tile=%dx%d", thumbWidth, thumbHeight, config.Columns, config.Rows)
|
tileFilter := fmt.Sprintf("scale=%d:%d,tile=%dx%d", thumbWidth, thumbHeight, config.Columns, config.Rows)
|
||||||
|
|
||||||
// Add timestamp overlay if requested
|
// Build video filter
|
||||||
if config.ShowTimestamp {
|
var vfilter string
|
||||||
// This is complex for contact sheets, skip for now
|
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
|
// Build FFmpeg command
|
||||||
args := []string{
|
args := []string{
|
||||||
"-i", config.VideoPath,
|
"-i", config.VideoPath,
|
||||||
"-vf", fmt.Sprintf("%s,%s", selectFilter, tileFilter),
|
"-vf", vfilter,
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-y",
|
"-y",
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +330,57 @@ func (g *Generator) generateContactSheet(ctx context.Context, config Config, dur
|
||||||
return outputPath, nil
|
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
|
||||||
|
// 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:x=10:y=10,"+
|
||||||
|
"drawtext=text='%s':fontcolor=white:fontsize=12:x=10:y=35",
|
||||||
|
selectFilter,
|
||||||
|
tileFilter,
|
||||||
|
sheetWidth,
|
||||||
|
sheetHeight+headerHeight,
|
||||||
|
headerHeight,
|
||||||
|
line1,
|
||||||
|
line2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
// calculateTimestamps generates timestamps for thumbnail extraction
|
// calculateTimestamps generates timestamps for thumbnail extraction
|
||||||
func (g *Generator) calculateTimestamps(config Config, duration float64) []float64 {
|
func (g *Generator) calculateTimestamps(config Config, duration float64) []float64 {
|
||||||
var timestamps []float64
|
var timestamps []float64
|
||||||
|
|
|
||||||
65
main.go
65
main.go
|
|
@ -1709,6 +1709,30 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If thumb module, load video into thumb slot
|
||||||
|
if moduleID == "thumb" {
|
||||||
|
path := videoPaths[0]
|
||||||
|
go func() {
|
||||||
|
src, err := probeVideo(path)
|
||||||
|
if err != nil {
|
||||||
|
logging.Debug(logging.CatModule, "failed to load video for thumb: %v", err)
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
|
||||||
|
}, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state and show module (with small delay to allow flash animation)
|
||||||
|
time.Sleep(350 * time.Millisecond)
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
s.thumbFile = src
|
||||||
|
s.showModule(moduleID)
|
||||||
|
logging.Debug(logging.CatModule, "loaded video for thumb module")
|
||||||
|
}, false)
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Single file or non-convert module: load first video and show module
|
// Single file or non-convert module: load first video and show module
|
||||||
path := videoPaths[0]
|
path := videoPaths[0]
|
||||||
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
|
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
|
||||||
|
|
@ -6773,6 +6797,47 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If in thumb module, handle single video file
|
||||||
|
if s.active == "thumb" {
|
||||||
|
// Collect video files from dropped items
|
||||||
|
var videoPaths []string
|
||||||
|
for _, uri := range items {
|
||||||
|
if uri.Scheme() != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := uri.Path()
|
||||||
|
if s.isVideoFile(path) {
|
||||||
|
videoPaths = append(videoPaths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(videoPaths) == 0 {
|
||||||
|
logging.Debug(logging.CatUI, "no valid video files in dropped items")
|
||||||
|
dialog.ShowInformation("Thumbnail Generation", "No video files found in dropped items.", s.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load first video
|
||||||
|
go func() {
|
||||||
|
src, err := probeVideo(videoPaths[0])
|
||||||
|
if err != nil {
|
||||||
|
logging.Debug(logging.CatModule, "failed to load video for thumb: %v", err)
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
|
||||||
|
}, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
s.thumbFile = src
|
||||||
|
s.showThumbView()
|
||||||
|
logging.Debug(logging.CatModule, "loaded video into thumb module")
|
||||||
|
}, false)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If in merge module, handle multiple video files
|
// If in merge module, handle multiple video files
|
||||||
if s.active == "merge" {
|
if s.active == "merge" {
|
||||||
// Collect all video files from the dropped items
|
// Collect all video files from the dropped items
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user