diff --git a/internal/thumbnail/generator.go b/internal/thumbnail/generator.go index 1a3f860..b9c09dd 100644 --- a/internal/thumbnail/generator.go +++ b/internal/thumbnail/generator.go @@ -299,15 +299,19 @@ func (g *Generator) generateContactSheet(ctx context.Context, config Config, dur // Build tile filter tileFilter := fmt.Sprintf("scale=%d:%d,tile=%dx%d", thumbWidth, thumbHeight, config.Columns, config.Rows) - // Add timestamp overlay if requested - if config.ShowTimestamp { - // This is complex for contact sheets, skip for now + // 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", fmt.Sprintf("%s,%s", selectFilter, tileFilter), + "-vf", vfilter, "-frames:v", "1", "-y", } @@ -326,6 +330,57 @@ func (g *Generator) generateContactSheet(ctx context.Context, config Config, dur 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 func (g *Generator) calculateTimestamps(config Config, duration float64) []float64 { var timestamps []float64 diff --git a/main.go b/main.go index 78675ab..42c739e 100644 --- a/main.go +++ b/main.go @@ -1709,6 +1709,30 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { 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 path := videoPaths[0] 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 } + // 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 s.active == "merge" { // Collect all video files from the dropped items