Compare commits
2 Commits
4e66b317bc
...
db35300723
| Author | SHA1 | Date | |
|---|---|---|---|
| db35300723 | |||
| 93c5d0d6d4 |
|
|
@ -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
|
||||
|
|
|
|||
225
main.go
225
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)
|
||||
|
|
@ -3207,19 +3231,13 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
|
|||
inputPath := cfg["inputPath"].(string)
|
||||
outputPath := cfg["outputPath"].(string)
|
||||
|
||||
conv := s.convert
|
||||
if cfgJSON, ok := cfg["convertConfig"].(string); ok && cfgJSON != "" {
|
||||
_ = json.Unmarshal([]byte(cfgJSON), &conv)
|
||||
}
|
||||
if conv.OutputAspect == "" {
|
||||
conv.OutputAspect = "Source"
|
||||
}
|
||||
|
||||
// Probe video to get duration
|
||||
src, err := probeVideo(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate start time centered on midpoint
|
||||
center := math.Max(0, src.Duration/2-10)
|
||||
start := fmt.Sprintf("%.2f", center)
|
||||
|
||||
|
|
@ -3227,148 +3245,19 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
|
|||
progressCallback(0)
|
||||
}
|
||||
|
||||
// Use stream copy to extract snippet without re-encoding
|
||||
args := []string{
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", start,
|
||||
"-i", inputPath,
|
||||
"-t", "20",
|
||||
"-c", "copy", // Copy all streams without re-encoding
|
||||
"-map", "0", // Include all streams
|
||||
outputPath,
|
||||
}
|
||||
|
||||
hasCoverArt := strings.TrimSpace(conv.CoverArtPath) != ""
|
||||
if hasCoverArt {
|
||||
args = append(args, "-i", conv.CoverArtPath)
|
||||
}
|
||||
|
||||
var vf []string
|
||||
if conv.TargetResolution != "" && conv.TargetResolution != "Source" {
|
||||
var scaleFilter string
|
||||
switch conv.TargetResolution {
|
||||
case "720p":
|
||||
scaleFilter = "scale=-2:720"
|
||||
case "1080p":
|
||||
scaleFilter = "scale=-2:1080"
|
||||
case "1440p":
|
||||
scaleFilter = "scale=-2:1440"
|
||||
case "4K":
|
||||
scaleFilter = "scale=-2:2160"
|
||||
case "8K":
|
||||
scaleFilter = "scale=-2:4320"
|
||||
case "NTSC (720×480)":
|
||||
scaleFilter = "scale=720:480"
|
||||
case "PAL (720×540)":
|
||||
scaleFilter = "scale=720:540"
|
||||
case "PAL (720×576)":
|
||||
scaleFilter = "scale=720:576"
|
||||
}
|
||||
if scaleFilter != "" {
|
||||
vf = append(vf, scaleFilter)
|
||||
}
|
||||
}
|
||||
|
||||
aspectExplicit := conv.OutputAspect != "" && !strings.EqualFold(conv.OutputAspect, "Source")
|
||||
if aspectExplicit {
|
||||
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
|
||||
targetAspect := resolveTargetAspect(conv.OutputAspect, src)
|
||||
aspectConversionNeeded := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01)
|
||||
if aspectConversionNeeded {
|
||||
vf = append(vf, aspectFilters(targetAspect, conv.AspectHandling)...)
|
||||
}
|
||||
}
|
||||
|
||||
if conv.FrameRate != "" && conv.FrameRate != "Source" {
|
||||
vf = append(vf, "fps="+conv.FrameRate)
|
||||
}
|
||||
|
||||
forcedCodec := !strings.EqualFold(conv.VideoCodec, "Copy")
|
||||
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
|
||||
needsReencode := len(vf) > 0 || isWMV || forcedCodec
|
||||
|
||||
if len(vf) > 0 {
|
||||
args = append(args, "-vf", strings.Join(vf, ","))
|
||||
}
|
||||
|
||||
if hasCoverArt {
|
||||
args = append(args, "-map", "0", "-map", "1:v")
|
||||
} else {
|
||||
args = append(args, "-map", "0")
|
||||
}
|
||||
|
||||
if !needsReencode {
|
||||
if hasCoverArt {
|
||||
args = append(args, "-c:v:0", "copy")
|
||||
} else {
|
||||
args = append(args, "-c:v", "copy")
|
||||
}
|
||||
} else {
|
||||
videoCodec := determineVideoCodec(conv)
|
||||
if videoCodec == "copy" {
|
||||
videoCodec = "libx264"
|
||||
}
|
||||
args = append(args, "-c:v", videoCodec)
|
||||
|
||||
mode := conv.BitrateMode
|
||||
if mode == "" {
|
||||
mode = "CRF"
|
||||
}
|
||||
switch mode {
|
||||
case "CBR", "VBR":
|
||||
vb := conv.VideoBitrate
|
||||
if vb == "" {
|
||||
vb = defaultBitrate(conv.VideoCodec, src.Width, src.Bitrate)
|
||||
}
|
||||
args = append(args, "-b:v", vb)
|
||||
if mode == "CBR" {
|
||||
args = append(args, "-minrate", vb, "-maxrate", vb, "-bufsize", vb)
|
||||
}
|
||||
default:
|
||||
crf := conv.CRF
|
||||
if crf == "" {
|
||||
crf = crfForQuality(conv.Quality)
|
||||
}
|
||||
if videoCodec == "libx264" || videoCodec == "libx265" {
|
||||
args = append(args, "-crf", crf)
|
||||
}
|
||||
}
|
||||
|
||||
if conv.EncoderPreset != "" && (strings.Contains(videoCodec, "264") || strings.Contains(videoCodec, "265")) {
|
||||
args = append(args, "-preset", conv.EncoderPreset)
|
||||
}
|
||||
|
||||
if conv.PixelFormat != "" {
|
||||
args = append(args, "-pix_fmt", conv.PixelFormat)
|
||||
}
|
||||
}
|
||||
|
||||
if hasCoverArt {
|
||||
args = append(args, "-c:v:1", "png", "-disposition:v:1", "attached_pic")
|
||||
}
|
||||
|
||||
if !needsReencode {
|
||||
args = append(args, "-c:a", "copy")
|
||||
} else {
|
||||
audioCodec := determineAudioCodec(conv)
|
||||
if audioCodec == "copy" {
|
||||
audioCodec = "aac"
|
||||
}
|
||||
args = append(args, "-c:a", audioCodec)
|
||||
if conv.AudioBitrate != "" && audioCodec != "flac" {
|
||||
args = append(args, "-b:a", conv.AudioBitrate)
|
||||
}
|
||||
if conv.AudioChannels != "" && conv.AudioChannels != "Source" {
|
||||
switch conv.AudioChannels {
|
||||
case "Mono":
|
||||
args = append(args, "-ac", "1")
|
||||
case "Stereo":
|
||||
args = append(args, "-ac", "2")
|
||||
case "5.1":
|
||||
args = append(args, "-ac", "6")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-t", "20", outputPath)
|
||||
|
||||
logFile, logPath, _ := createConversionLog(inputPath, outputPath, args)
|
||||
cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...)
|
||||
utils.ApplyNoWindow(cmd)
|
||||
|
|
@ -5041,24 +4930,23 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
return
|
||||
}
|
||||
src := state.source
|
||||
ext := state.convert.SelectedFormat.Ext
|
||||
// Use same extension as source file since we're using stream copy
|
||||
ext := filepath.Ext(src.Path)
|
||||
if ext == "" {
|
||||
ext = ".mp4"
|
||||
}
|
||||
outName := fmt.Sprintf("%s-snippet-%d%s", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix(), ext)
|
||||
outPath := filepath.Join(filepath.Dir(src.Path), outName)
|
||||
|
||||
cfgBytes, _ := json.Marshal(state.convert)
|
||||
job := &queue.Job{
|
||||
Type: queue.JobTypeSnippet,
|
||||
Title: "Snippet: " + filepath.Base(src.Path),
|
||||
Description: "20s snippet centred on midpoint",
|
||||
Description: "20s snippet centred on midpoint (source settings)",
|
||||
InputFile: src.Path,
|
||||
OutputFile: outPath,
|
||||
Config: map[string]interface{}{
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
"convertConfig": string(cfgBytes),
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
},
|
||||
}
|
||||
state.jobQueue.Add(job)
|
||||
|
|
@ -6773,6 +6661,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user