Compare commits
4 Commits
0ba53701b4
...
50237f741a
| Author | SHA1 | Date | |
|---|---|---|---|
| 50237f741a | |||
| 56141be0d4 | |||
| f1d445dd0a | |||
| d6fd5fc762 |
|
|
@ -284,15 +284,17 @@ func (g *Generator) generateContactSheet(ctx context.Context, config Config, dur
|
|||
tempConfig.Interval = 0
|
||||
timestamps := g.calculateTimestamps(tempConfig, duration)
|
||||
|
||||
// Build select filter for timestamps
|
||||
// Build select filter using timestamps (more reliable than frame numbers)
|
||||
// Use gte(t,timestamp) approach to select frames closest to target times
|
||||
selectFilter := "select='"
|
||||
for i, ts := range timestamps {
|
||||
if i > 0 {
|
||||
selectFilter += "+"
|
||||
}
|
||||
selectFilter += fmt.Sprintf("eq(n\\,%d)", int(ts*30)) // Assuming 30fps, should calculate actual fps
|
||||
// Select frame at or after this timestamp, limiting to one frame per timestamp
|
||||
selectFilter += fmt.Sprintf("gte(t\\,%.2f)*lt(t\\,%.2f)", ts, ts+0.1)
|
||||
}
|
||||
selectFilter += "'"
|
||||
selectFilter += "',setpts=N/TB"
|
||||
|
||||
outputPath := filepath.Join(config.OutputDir, fmt.Sprintf("contact_sheet.%s", config.Format))
|
||||
|
||||
|
|
@ -362,11 +364,12 @@ func (g *Generator) buildMetadataFilter(config Config, duration float64, thumbWi
|
|||
|
||||
// Create filter that:
|
||||
// 1. Generates contact sheet from selected frames
|
||||
// 2. Creates a blank header area
|
||||
// 2. Creates a blank header area with app background color
|
||||
// 3. Draws metadata text on header (using monospace font)
|
||||
// 4. Stacks header on top of contact sheet
|
||||
// App background color: #0B0F1A (dark blue)
|
||||
filter := fmt.Sprintf(
|
||||
"%s,%s,pad=%d:%d:0:%d:black,"+
|
||||
"%s,%s,pad=%d:%d:0:%d:0x0B0F1A,"+
|
||||
"drawtext=text='%s':fontcolor=white:fontsize=14:font='DejaVu Sans Mono':x=10:y=10,"+
|
||||
"drawtext=text='%s':fontcolor=white:fontsize=12:font='DejaVu Sans Mono':x=10:y=35",
|
||||
selectFilter,
|
||||
|
|
|
|||
269
main.go
269
main.go
|
|
@ -617,13 +617,13 @@ type appState struct {
|
|||
mergeChapters bool
|
||||
|
||||
// Thumbnail module state
|
||||
thumbFile *videoSource
|
||||
thumbCount int
|
||||
thumbWidth int
|
||||
thumbContactSheet bool
|
||||
thumbColumns int
|
||||
thumbRows int
|
||||
thumbGenerating bool
|
||||
thumbFile *videoSource
|
||||
thumbCount int
|
||||
thumbWidth int
|
||||
thumbContactSheet bool
|
||||
thumbColumns int
|
||||
thumbRows int
|
||||
thumbLastOutputPath string // Path to last generated output
|
||||
|
||||
// Interlacing detection state
|
||||
interlaceResult *interlace.DetectionResult
|
||||
|
|
@ -2339,7 +2339,7 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall
|
|||
case queue.JobTypeAudio:
|
||||
return fmt.Errorf("audio jobs not yet implemented")
|
||||
case queue.JobTypeThumb:
|
||||
return fmt.Errorf("thumb jobs not yet implemented")
|
||||
return s.executeThumbJob(ctx, job, progressCallback)
|
||||
case queue.JobTypeSnippet:
|
||||
return s.executeSnippetJob(ctx, job, progressCallback)
|
||||
default:
|
||||
|
|
@ -3226,6 +3226,49 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
|
|||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
|
||||
cfg := job.Config
|
||||
inputPath := cfg["inputPath"].(string)
|
||||
|
|
@ -9619,96 +9662,147 @@ func buildThumbView(state *appState) fyne.CanvasObject {
|
|||
)
|
||||
}
|
||||
|
||||
// Generate button
|
||||
generateBtn := widget.NewButton("Generate Thumbnails", func() {
|
||||
// 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 smaller width to fit window
|
||||
count = state.thumbColumns * state.thumbRows
|
||||
width = 200 // Smaller width for contact sheets to fit larger grids
|
||||
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
|
||||
}
|
||||
|
||||
state.thumbGenerating = true
|
||||
state.showThumbView()
|
||||
if state.jobQueue == nil {
|
||||
dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 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))
|
||||
job := createThumbJob()
|
||||
state.jobQueue.Add(job)
|
||||
|
||||
generator := thumbnail.NewGenerator(platformConfig.FFmpegPath)
|
||||
// Start the queue if not already running
|
||||
if !state.jobQueue.IsRunning() {
|
||||
state.jobQueue.Start()
|
||||
logging.Debug(logging.CatSystem, "started queue from Generate Now")
|
||||
}
|
||||
|
||||
// Configure based on mode
|
||||
var count, width int
|
||||
if state.thumbContactSheet {
|
||||
// Contact sheet: count is determined by grid, use default width
|
||||
count = state.thumbColumns * state.thumbRows
|
||||
width = 320 // Fixed width for contact sheets
|
||||
} else {
|
||||
// Individual thumbnails: use user settings
|
||||
count = state.thumbCount
|
||||
width = state.thumbWidth
|
||||
}
|
||||
|
||||
config := thumbnail.Config{
|
||||
VideoPath: state.thumbFile.Path,
|
||||
OutputDir: outputDir,
|
||||
Count: count,
|
||||
Width: width,
|
||||
Format: "jpg",
|
||||
Quality: 85,
|
||||
ContactSheet: state.thumbContactSheet,
|
||||
Columns: state.thumbColumns,
|
||||
Rows: state.thumbRows,
|
||||
ShowTimestamp: true,
|
||||
ShowMetadata: true,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
result, err := generator.Generate(ctx, config)
|
||||
|
||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||
state.thumbGenerating = false
|
||||
|
||||
if err != nil {
|
||||
logging.Debug(logging.CatSystem, "thumbnail generation failed: %v", err)
|
||||
dialog.ShowError(fmt.Errorf("Thumbnail generation failed: %w", err), state.window)
|
||||
state.showThumbView()
|
||||
return
|
||||
}
|
||||
|
||||
logging.Debug(logging.CatSystem, "generated %d thumbnails", len(result.Thumbnails))
|
||||
|
||||
// Show success dialog with option to open folder
|
||||
confirmDialog := dialog.NewConfirm(
|
||||
"Thumbnails Generated",
|
||||
fmt.Sprintf("Successfully generated %d thumbnail(s) at:\n%s\n\nOpen folder?",
|
||||
len(result.Thumbnails), outputDir),
|
||||
func(open bool) {
|
||||
if open {
|
||||
openFolder(outputDir)
|
||||
}
|
||||
},
|
||||
state.window,
|
||||
)
|
||||
confirmDialog.SetConfirmText("Open Folder")
|
||||
confirmDialog.SetDismissText("Close")
|
||||
confirmDialog.Show()
|
||||
|
||||
state.showThumbView()
|
||||
}, false)
|
||||
}()
|
||||
dialog.ShowInformation("Thumbnails", "Thumbnail generation started! View progress in Job Queue.", state.window)
|
||||
})
|
||||
generateBtn.Importance = widget.HighImportance
|
||||
generateNowBtn.Importance = widget.HighImportance
|
||||
|
||||
if state.thumbFile == nil {
|
||||
generateBtn.Disable()
|
||||
generateNowBtn.Disable()
|
||||
}
|
||||
|
||||
if state.thumbGenerating {
|
||||
generateBtn.SetText("Generating...")
|
||||
generateBtn.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 the 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
|
||||
img.SetMinSize(fyne.NewSize(800, 600))
|
||||
|
||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||
d := dialog.NewCustom("Contact Sheet", "Close", img, state.window)
|
||||
d.Resize(fyne.NewSize(900, 700))
|
||||
d.Show()
|
||||
}, false)
|
||||
}()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open folder
|
||||
openFolder(outputDir)
|
||||
})
|
||||
viewResultsBtn.Importance = widget.MediumImportance
|
||||
if state.thumbFile == nil {
|
||||
viewResultsBtn.Disable()
|
||||
}
|
||||
|
||||
// Settings panel
|
||||
|
|
@ -9718,7 +9812,10 @@ func buildThumbView(state *appState) fyne.CanvasObject {
|
|||
contactSheetCheck,
|
||||
settingsOptions,
|
||||
widget.NewSeparator(),
|
||||
generateBtn,
|
||||
generateNowBtn,
|
||||
addQueueBtn,
|
||||
viewQueueBtn,
|
||||
viewResultsBtn,
|
||||
)
|
||||
|
||||
// Main content - split layout with preview on left, settings on right
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user