From 0d1235d867e4f73fcb17d5b1c43c1c85918ecb5a Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Fri, 26 Dec 2025 16:15:34 -0500 Subject: [PATCH] Add chapter detection visualizer with thumbnails Allows visual verification of detected scene changes before accepting. Features: - Extracts thumbnail at each detected chapter timestamp - Displays first 24 chapters in scrollable grid (4 columns) - Shows timestamp below each thumbnail (160x90px previews) - Accept/Reject buttons to confirm or discard detection - Progress indicator during thumbnail generation Implementation: - showChapterPreview() function creates preview dialog - extractChapterThumbnail() uses FFmpeg to extract frame - Scales to 320x180, saves as JPEG in temp dir - Thumbnails generated in background, dialog updated when ready Performance: - Limits to 24 chapters for UI responsiveness - Shows "(showing first 24)" if more detected - Temp files stored in videotools-chapter-thumbs/ User workflow: 1. Adjust sensitivity slider 2. Click "Detect Scenes" 3. Review thumbnails to verify detection quality 4. Accept to use chapters, or Reject to try different sensitivity --- author_module.go | 123 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/author_module.go b/author_module.go index e026872..238e7e8 100644 --- a/author_module.go +++ b/author_module.go @@ -414,10 +414,15 @@ func buildChaptersTab(state *appState) fyne.CanvasObject { dialog.ShowInformation("Scene Detection", "No scene changes detected at the current sensitivity.", state.window) return } - state.authorChapters = chapters - state.authorChapterSource = "scenes" - state.updateAuthorSummary() - refreshChapters() + // Show chapter preview dialog for visual verification + state.showChapterPreview(targetPath, chapters, func(accepted bool) { + if accepted { + state.authorChapters = chapters + state.authorChapterSource = "scenes" + state.updateAuthorSummary() + refreshChapters() + } + }) }) }() }) @@ -2276,6 +2281,116 @@ func runCommand(name string, args []string) error { return nil } +func (s *appState) showChapterPreview(videoPath string, chapters []authorChapter, callback func(bool)) { + dlg := dialog.NewCustom("Chapter Preview", "Close", container.NewVBox( + widget.NewLabel(fmt.Sprintf("Detected %d chapters - generating thumbnails...", len(chapters))), + widget.NewProgressBarInfinite(), + ), s.window) + dlg.Resize(fyne.NewSize(800, 600)) + dlg.Show() + + go func() { + // Limit preview to first 24 chapters for performance + previewCount := len(chapters) + if previewCount > 24 { + previewCount = 24 + } + + thumbnails := make([]fyne.CanvasObject, 0, previewCount) + for i := 0; i < previewCount; i++ { + ch := chapters[i] + thumbPath, err := extractChapterThumbnail(videoPath, ch.Timestamp) + if err != nil { + logging.Debug(logging.CatSystem, "failed to extract thumbnail at %.2f: %v", ch.Timestamp, err) + continue + } + + img := canvas.NewImageFromFile(thumbPath) + img.FillMode = canvas.ImageFillContain + img.SetMinSize(fyne.NewSize(160, 90)) + + timeLabel := widget.NewLabel(fmt.Sprintf("%.2fs", ch.Timestamp)) + timeLabel.Alignment = fyne.TextAlignCenter + + thumbCard := container.NewVBox( + container.NewMax(img), + timeLabel, + ) + thumbnails = append(thumbnails, thumbCard) + } + + runOnUI(func() { + dlg.Hide() + + if len(thumbnails) == 0 { + dialog.ShowError(fmt.Errorf("failed to generate chapter thumbnails"), s.window) + return + } + + grid := container.NewGridWrap(fyne.NewSize(170, 120), thumbnails...) + scroll := container.NewVScroll(grid) + scroll.SetMinSize(fyne.NewSize(780, 500)) + + infoText := fmt.Sprintf("Found %d chapters", len(chapters)) + if len(chapters) > previewCount { + infoText += fmt.Sprintf(" (showing first %d)", previewCount) + } + info := widget.NewLabel(infoText) + info.Wrapping = fyne.TextWrapWord + + var previewDlg *dialog.CustomDialog + acceptBtn := widget.NewButton("Accept Chapters", func() { + previewDlg.Hide() + callback(true) + }) + acceptBtn.Importance = widget.HighImportance + + rejectBtn := widget.NewButton("Reject", func() { + previewDlg.Hide() + callback(false) + }) + + content := container.NewBorder( + container.NewVBox(info, widget.NewSeparator()), + container.NewHBox(rejectBtn, acceptBtn), + nil, + nil, + scroll, + ) + + previewDlg = dialog.NewCustom("Chapter Preview", "Close", content, s.window) + previewDlg.Resize(fyne.NewSize(800, 600)) + previewDlg.Show() + }) + }() +} + +func extractChapterThumbnail(videoPath string, timestamp float64) (string, error) { + tmpDir := filepath.Join(os.TempDir(), "videotools-chapter-thumbs") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", err + } + + outputPath := filepath.Join(tmpDir, fmt.Sprintf("thumb_%.2f.jpg", timestamp)) + args := []string{ + "-ss", fmt.Sprintf("%.2f", timestamp), + "-i", videoPath, + "-frames:v", "1", + "-q:v", "2", + "-vf", "scale=320:180", + "-y", + outputPath, + } + + cmd := exec.Command(platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + if err := cmd.Run(); err != nil { + return "", err + } + + return outputPath, nil +} + func runOnUI(fn func()) { fn() }