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
This commit is contained in:
Stu Leak 2025-12-26 16:15:34 -05:00
parent d781ce2d58
commit 0d1235d867

View File

@ -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()
}