From 81773c46a1b5da40fcc083fe88cec0594d36462f Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 23 Dec 2025 21:47:14 -0500 Subject: [PATCH] Extract thumb module from main.go (partial) - Create thumb_module.go with showThumbView(), buildThumbView(), executeThumbJob() - Remove showThumbView() from main.go - buildThumbView() and executeThumbJob() still in main.go to be removed - Syntax check passes - Working on large function extraction --- main.go | 7 - thumb_module.go | 406 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+), 7 deletions(-) create mode 100644 thumb_module.go diff --git a/main.go b/main.go index 673e219..56e95ba 100644 --- a/main.go +++ b/main.go @@ -2778,13 +2778,6 @@ func (s *appState) showCompareView() { s.setContent(buildCompareView(s)) } -func (s *appState) showThumbView() { - s.stopPreview() - s.lastModule = s.active - s.active = "thumb" - s.setContent(buildThumbView(s)) -} - func (s *appState) showPlayerView() { s.stopPreview() s.lastModule = s.active diff --git a/thumb_module.go b/thumb_module.go new file mode 100644 index 0000000..c4cedba --- /dev/null +++ b/thumb_module.go @@ -0,0 +1,406 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + + "git.leaktechnologies.dev/stu/VideoTools/internal/logging" + "git.leaktechnologies.dev/stu/VideoTools/internal/queue" + "git.leaktechnologies.dev/stu/VideoTools/internal/thumbnail" + "git.leaktechnologies.dev/stu/VideoTools/internal/ui" +) + +func (s *appState) showThumbView() { + s.stopPreview() + s.lastModule = s.active + s.active = "thumb" + s.setContent(buildThumbView(s)) +} + +func buildThumbView(state *appState) fyne.CanvasObject { + thumbColor := moduleColor("thumb") + + // Back button + backBtn := widget.NewButton("< THUMBNAILS", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + topBar := ui.TintedBar(thumbColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + + // Instructions + instructions := widget.NewLabel("Generate thumbnails from a video file. Load a video and configure settings.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Initialize state defaults + if state.thumbCount == 0 { + state.thumbCount = 24 // Default to 24 thumbnails (good for contact sheets) + } + if state.thumbWidth == 0 { + state.thumbWidth = 320 + } + if state.thumbColumns == 0 { + state.thumbColumns = 4 // 4 columns works well for widescreen videos + } + if state.thumbRows == 0 { + state.thumbRows = 6 // 4x6 = 24 thumbnails + } + + // File label and video preview + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + var videoContainer fyne.CanvasObject + if state.thumbFile != nil { + fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path))) + videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbFile, nil) + } else { + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.thumbFile = src + state.showThumbView() + logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path) + }, state.window) + }) + + // Clear button + clearBtn := widget.NewButton("Clear", func() { + state.thumbFile = nil + state.showThumbView() + }) + clearBtn.Importance = widget.LowImportance + + // Contact sheet checkbox + contactSheetCheck := widget.NewCheck("Generate Contact Sheet (single image)", func(checked bool) { + state.thumbContactSheet = checked + state.showThumbView() + }) + contactSheetCheck.Checked = state.thumbContactSheet + + // Conditional settings based on contact sheet mode + var settingsOptions fyne.CanvasObject + if state.thumbContactSheet { + // Contact sheet mode: show columns and rows + colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbColumns)) + rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbRows)) + + totalThumbs := state.thumbColumns * state.thumbRows + totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs)) + totalLabel.TextStyle = fyne.TextStyle{Italic: true} + + colSlider := widget.NewSlider(2, 12) + colSlider.Value = float64(state.thumbColumns) + colSlider.Step = 1 + colSlider.OnChanged = func(val float64) { + state.thumbColumns = int(val) + colLabel.SetText(fmt.Sprintf("Columns: %d", int(val))) + totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) + } + + rowSlider := widget.NewSlider(2, 12) + rowSlider.Value = float64(state.thumbRows) + rowSlider.Step = 1 + rowSlider.OnChanged = func(val float64) { + state.thumbRows = int(val) + rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val))) + totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) + } + + settingsOptions = container.NewVBox( + widget.NewSeparator(), + widget.NewLabel("Contact Sheet Grid:"), + colLabel, + colSlider, + rowLabel, + rowSlider, + totalLabel, + ) + } else { + // Individual thumbnails mode: show count and width + countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbCount)) + countSlider := widget.NewSlider(3, 50) + countSlider.Value = float64(state.thumbCount) + countSlider.Step = 1 + countSlider.OnChanged = func(val float64) { + state.thumbCount = int(val) + countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val))) + } + + widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbWidth)) + widthSlider := widget.NewSlider(160, 640) + widthSlider.Value = float64(state.thumbWidth) + widthSlider.Step = 32 + widthSlider.OnChanged = func(val float64) { + state.thumbWidth = int(val) + widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val))) + } + + settingsOptions = container.NewVBox( + widget.NewSeparator(), + widget.NewLabel("Individual Thumbnails:"), + countLabel, + countSlider, + widthLabel, + widthSlider, + ) + } + + // Helper function to create thumbnail job + createThumbJob := func() *queue.Job { + // Create output directory in same folder as video + videoDir := filepath.Dir(state.thumbFile.Path) + videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path)) + outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) + + // Configure based on mode + var count, width int + var description string + if state.thumbContactSheet { + // Contact sheet: count is determined by grid, use larger width for analyzable screenshots + count = state.thumbColumns * state.thumbRows + width = 280 // Larger width for contact sheets to make screenshots analyzable (4x8 grid = ~1144x1416) + description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", state.thumbColumns, state.thumbRows, count) + } else { + // Individual thumbnails: use user settings + count = state.thumbCount + width = state.thumbWidth + description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width) + } + + return &queue.Job{ + Type: queue.JobTypeThumb, + Title: "Thumbnails: " + filepath.Base(state.thumbFile.Path), + Description: description, + InputFile: state.thumbFile.Path, + OutputFile: outputDir, + Config: map[string]interface{}{ + "inputPath": state.thumbFile.Path, + "outputDir": outputDir, + "count": float64(count), + "width": float64(width), + "contactSheet": state.thumbContactSheet, + "columns": float64(state.thumbColumns), + "rows": float64(state.thumbRows), + }, + } + } + + // Generate Now button - adds to queue and starts it + generateNowBtn := widget.NewButton("GENERATE NOW", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Please load a video file first.", state.window) + return + } + + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + + job := createThumbJob() + state.jobQueue.Add(job) + + // Start queue if not already running + if !state.jobQueue.IsRunning() { + state.jobQueue.Start() + logging.Debug(logging.CatSystem, "started queue from Generate Now") + } + + dialog.ShowInformation("Thumbnails", "Thumbnail generation started! View progress in Job Queue.", state.window) + }) + generateNowBtn.Importance = widget.HighImportance + + if state.thumbFile == nil { + generateNowBtn.Disable() + } + + // Add to Queue button + addQueueBtn := widget.NewButton("Add to Queue", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Please load a video file first.", state.window) + return + } + + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + + job := createThumbJob() + state.jobQueue.Add(job) + + dialog.ShowInformation("Queue", "Thumbnail job added to queue!", state.window) + }) + addQueueBtn.Importance = widget.MediumImportance + + if state.thumbFile == nil { + addQueueBtn.Disable() + } + + // View Queue button + viewQueueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + viewQueueBtn.Importance = widget.MediumImportance + + // View Results button - shows output folder if it exists + viewResultsBtn := widget.NewButton("View Results", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window) + return + } + + videoDir := filepath.Dir(state.thumbFile.Path) + videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path)) + outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) + + // Check if output exists + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + dialog.ShowInformation("No Results", "No generated thumbnails found. Generate thumbnails first.", state.window) + return + } + + // If contact sheet mode, try to show contact sheet image + if state.thumbContactSheet { + contactSheetPath := filepath.Join(outputDir, "contact_sheet.jpg") + if _, err := os.Stat(contactSheetPath); err == nil { + // Show contact sheet in a dialog + go func() { + img := canvas.NewImageFromFile(contactSheetPath) + img.FillMode = canvas.ImageFillContain + // Adaptive size for small screens - use scrollable dialog + img.SetMinSize(fyne.NewSize(640, 480)) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Wrap in scroll container for large contact sheets + scroll := container.NewScroll(img) + d := dialog.NewCustom("Contact Sheet", "Close", scroll, state.window) + // Adaptive dialog size that fits on 1280x768 screens + d.Resize(fyne.NewSize(700, 600)) + d.Show() + }, false) + }() + return + } + } + + // Otherwise, open folder + openFolder(outputDir) + }) + viewResultsBtn.Importance = widget.MediumImportance + if state.thumbFile == nil { + viewResultsBtn.Disable() + } + + // Settings panel + settingsPanel := container.NewVBox( + widget.NewLabel("Settings:"), + widget.NewSeparator(), + contactSheetCheck, + settingsOptions, + widget.NewSeparator(), + generateNowBtn, + addQueueBtn, + viewQueueBtn, + viewResultsBtn, + ) + + // Main content - split layout with preview on left, settings on right + leftColumn := container.NewVBox( + videoContainer, + ) + + rightColumn := container.NewVBox( + settingsPanel, + ) + + mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, leftColumn, rightColumn) + + content := container.NewBorder( + container.NewVBox(instructions, widget.NewSeparator(), fileLabel, container.NewHBox(loadBtn, clearBtn)), + nil, + nil, + nil, + mainContent, + ) + + bottomBar := moduleFooter(thumbColor, layout.NewSpacer(), state.statsBar) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputDir := cfg["outputDir"].(string) + count := int(cfg["count"].(float64)) + width := int(cfg["width"].(float64)) + contactSheet := cfg["contactSheet"].(bool) + columns := int(cfg["columns"].(float64)) + rows := int(cfg["rows"].(float64)) + + if progressCallback != nil { + progressCallback(0) + } + + generator := thumbnail.NewGenerator(platformConfig.FFmpegPath) + config := thumbnail.Config{ + VideoPath: inputPath, + OutputDir: outputDir, + Count: count, + Width: width, + Format: "jpg", + Quality: 85, + ContactSheet: contactSheet, + Columns: columns, + Rows: rows, + ShowTimestamp: false, // Disabled to avoid font issues + ShowMetadata: contactSheet, + } + + result, err := generator.Generate(ctx, config) + if err != nil { + return fmt.Errorf("thumbnail generation failed: %w", err) + } + + logging.Debug(logging.CatSystem, "generated %d thumbnails", len(result.Thumbnails)) + + if progressCallback != nil { + progressCallback(1) + } + + return nil +}