Add initial Merge module with chapters and queue support

This commit is contained in:
Stu Leak 2025-12-09 16:10:23 -05:00
parent b97182baac
commit 3e7583704b

462
main.go
View File

@ -520,6 +520,19 @@ type appState struct {
compareFile2 *videoSource
inspectFile *videoSource
autoCompare bool // Auto-load Compare module after conversion
// Merge state
mergeClips []mergeClip
mergeFormat string
mergeOutput string
mergeKeepAll bool
mergeCodecMode string
}
type mergeClip struct {
Path string
Chapter string
Duration float64
}
func (s *appState) persistConvertConfig() {
@ -535,6 +548,35 @@ func (s *appState) stopPreview() {
}
}
func toString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
default:
return fmt.Sprintf("%v", v)
}
}
func toFloat(v interface{}) float64 {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case json.Number:
if f, err := t.Float64(); err == nil {
return f
}
}
return 0
}
func (s *appState) updateStatsBar() {
if s.statsBar == nil || s.jobQueue == nil {
return
@ -773,7 +815,7 @@ func (s *appState) showMainMenu() {
Label: m.Label,
Color: m.Color,
Category: m.Category,
Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect", // Convert, compare, and inspect modules are functional
Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect" || m.ID == "merge", // Enabled modules
})
}
@ -1109,6 +1151,8 @@ func (s *appState) showModule(id string) {
switch id {
case "convert":
s.showConvertView(nil)
case "merge":
s.showMergeView()
case "compare":
s.showCompareView()
case "inspect":
@ -1437,6 +1481,284 @@ func (s *appState) showInspectView() {
s.setContent(buildInspectView(s))
}
func (s *appState) showMergeView() {
s.stopPreview()
s.lastModule = s.active
s.active = "merge"
if s.mergeFormat == "" {
s.mergeFormat = "mkv-copy"
}
listBox := container.NewVBox()
var buildList func()
buildList = func() {
listBox.Objects = nil
if len(s.mergeClips) == 0 {
empty := widget.NewLabel("Add at least two clips to merge.")
empty.Alignment = fyne.TextAlignCenter
listBox.Add(container.NewCenter(empty))
} else {
for i, c := range s.mergeClips {
idx := i
name := filepath.Base(c.Path)
label := widget.NewLabel(utils.ShortenMiddle(name, 50))
chEntry := widget.NewEntry()
chEntry.SetText(c.Chapter)
chEntry.SetPlaceHolder(fmt.Sprintf("Part %d", i+1))
chEntry.OnChanged = func(val string) {
s.mergeClips[idx].Chapter = val
}
upBtn := widget.NewButton("↑", func() {
if idx > 0 {
s.mergeClips[idx-1], s.mergeClips[idx] = s.mergeClips[idx], s.mergeClips[idx-1]
buildList()
}
})
downBtn := widget.NewButton("↓", func() {
if idx < len(s.mergeClips)-1 {
s.mergeClips[idx+1], s.mergeClips[idx] = s.mergeClips[idx], s.mergeClips[idx+1]
buildList()
}
})
delBtn := widget.NewButton("Remove", func() {
s.mergeClips = append(s.mergeClips[:idx], s.mergeClips[idx+1:]...)
buildList()
})
row := container.NewBorder(
nil, nil,
container.NewVBox(upBtn, downBtn),
delBtn,
container.NewVBox(label, chEntry),
)
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
cardBg.CornerRadius = 6
cardBg.SetMinSize(fyne.NewSize(0, label.MinSize().Height+chEntry.MinSize().Height+12))
listBox.Add(container.NewPadded(container.NewMax(cardBg, row)))
}
}
listBox.Refresh()
}
addFiles := func(paths []string) {
for _, p := range paths {
src, err := probeVideo(p)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to probe %s: %w", p, err), s.window)
continue
}
s.mergeClips = append(s.mergeClips, mergeClip{
Path: p,
Chapter: strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)),
Duration: src.Duration,
})
}
if len(s.mergeClips) >= 2 && s.mergeOutput == "" {
first := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(first, "merged.mkv")
}
buildList()
}
addBtn := widget.NewButton("Add Files…", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
path := reader.URI().Path()
reader.Close()
addFiles([]string{path})
}, s.window)
})
clearBtn := widget.NewButton("Clear", func() {
s.mergeClips = nil
buildList()
})
formatMap := map[string]string{
"MKV (Copy if compatible)": "mkv-copy",
"MKV (Re-encode H.265)": "mkv-encode",
"DVD NTSC 16:9": "dvd-ntsc-169",
"DVD NTSC 4:3": "dvd-ntsc-43",
"DVD PAL 16:9": "dvd-pal-169",
"DVD PAL 4:3": "dvd-pal-43",
"Blu-ray (H.264, MKV container)": "bd-h264",
}
var formatKeys []string
for k := range formatMap {
formatKeys = append(formatKeys, k)
}
slices.Sort(formatKeys)
formatSelect := widget.NewSelect(formatKeys, func(val string) {
s.mergeFormat = formatMap[val]
switch {
case strings.HasPrefix(s.mergeFormat, "dvd"):
s.mergeCodecMode = "encode"
if s.mergeOutput == "" && len(s.mergeClips) > 0 {
dir := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(dir, "merged-dvd.mpg")
}
case s.mergeFormat == "bd-h264":
s.mergeCodecMode = "encode"
if s.mergeOutput == "" && len(s.mergeClips) > 0 {
dir := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(dir, "merged-bd.mkv")
}
default:
if s.mergeCodecMode == "" {
s.mergeCodecMode = "copy"
}
if s.mergeOutput == "" && len(s.mergeClips) > 0 {
dir := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(dir, "merged.mkv")
}
}
})
for label, val := range formatMap {
if val == s.mergeFormat {
formatSelect.SetSelected(label)
break
}
}
keepAllCheck := widget.NewCheck("Keep all audio/subtitle tracks", func(v bool) {
s.mergeKeepAll = v
})
keepAllCheck.SetChecked(s.mergeKeepAll)
codecModeSelect := widget.NewSelect([]string{"Copy (if compatible)", "Re-encode (H.265)"}, func(val string) {
if strings.HasPrefix(val, "Copy") {
s.mergeCodecMode = "copy"
} else {
s.mergeCodecMode = "encode"
}
})
if s.mergeCodecMode == "" {
s.mergeCodecMode = "copy"
}
if s.mergeCodecMode == "encode" {
codecModeSelect.SetSelected("Re-encode (H.265)")
} else {
codecModeSelect.SetSelected("Copy (if compatible)")
}
outputEntry := widget.NewEntry()
outputEntry.SetPlaceHolder("merged output path")
outputEntry.SetText(s.mergeOutput)
outputEntry.OnChanged = func(val string) {
s.mergeOutput = val
}
browseOut := widget.NewButton("Browse", func() {
dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) {
if err != nil || writer == nil {
return
}
s.mergeOutput = writer.URI().Path()
outputEntry.SetText(s.mergeOutput)
writer.Close()
}, s.window)
})
addQueueBtn := widget.NewButton("Add Merge to Queue", func() {
if err := s.addMergeToQueue(false); err != nil {
dialog.ShowError(err, s.window)
return
}
dialog.ShowInformation("Queue", "Merge job added to queue.", s.window)
if s.jobQueue != nil && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
})
runNowBtn := widget.NewButton("Merge Now", func() {
if err := s.addMergeToQueue(true); err != nil {
dialog.ShowError(err, s.window)
return
}
if s.jobQueue != nil && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
dialog.ShowInformation("Merge", "Merge started! Track progress in Job Queue.", s.window)
})
if len(s.mergeClips) < 2 {
addQueueBtn.Disable()
runNowBtn.Disable()
}
listScroll := container.NewVScroll(listBox)
listScroll.SetMinSize(fyne.NewSize(400, 300))
left := container.NewVBox(
widget.NewLabelWithStyle("Clips to Merge", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
container.NewHBox(addBtn, clearBtn),
listScroll,
)
right := container.NewVBox(
widget.NewLabelWithStyle("Output Options", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("Format"),
formatSelect,
codecModeSelect,
keepAllCheck,
widget.NewSeparator(),
widget.NewLabel("Output Path"),
container.NewBorder(nil, nil, nil, browseOut, outputEntry),
widget.NewSeparator(),
container.NewHBox(addQueueBtn, runNowBtn),
)
content := container.NewHSplit(left, right)
content.Offset = 0.55
s.setContent(container.NewBorder(nil, nil, nil, nil, content))
buildList()
}
func (s *appState) addMergeToQueue(startNow bool) error {
if len(s.mergeClips) < 2 {
return fmt.Errorf("add at least two clips")
}
if strings.TrimSpace(s.mergeOutput) == "" {
firstDir := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(firstDir, "merged.mkv")
}
clips := make([]map[string]interface{}, 0, len(s.mergeClips))
for _, c := range s.mergeClips {
name := c.Chapter
if strings.TrimSpace(name) == "" {
name = strings.TrimSuffix(filepath.Base(c.Path), filepath.Ext(c.Path))
}
clips = append(clips, map[string]interface{}{
"path": c.Path,
"chapter": name,
"duration": c.Duration,
})
}
config := map[string]interface{}{
"clips": clips,
"format": s.mergeFormat,
"keepAllStreams": s.mergeKeepAll,
"codecMode": s.mergeCodecMode,
"outputPath": s.mergeOutput,
}
job := &queue.Job{
Type: queue.JobTypeMerge,
Title: fmt.Sprintf("Merge %d clips", len(clips)),
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(s.mergeOutput), 40)),
InputFile: clips[0]["path"].(string),
OutputFile: s.mergeOutput,
Config: config,
}
s.jobQueue.Add(job)
if startNow && s.jobQueue != nil && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
return nil
}
func (s *appState) showCompareFullscreen() {
s.stopPreview()
s.lastModule = s.active
@ -1452,7 +1774,7 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall
case queue.JobTypeConvert:
return s.executeConvertJob(ctx, job, progressCallback)
case queue.JobTypeMerge:
return fmt.Errorf("merge jobs not yet implemented")
return s.executeMergeJob(ctx, job, progressCallback)
case queue.JobTypeTrim:
return fmt.Errorf("trim jobs not yet implemented")
case queue.JobTypeFilter:
@ -1470,6 +1792,142 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall
}
}
func (s *appState) executeMergeJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config
format, _ := cfg["format"].(string)
keepAll, _ := cfg["keepAllStreams"].(bool)
codecMode, _ := cfg["codecMode"].(string) // copy or encode
outputPath, _ := cfg["outputPath"].(string)
rawClips, _ := cfg["clips"].([]interface{})
var clips []mergeClip
for _, rc := range rawClips {
if m, ok := rc.(map[string]interface{}); ok {
clips = append(clips, mergeClip{
Path: toString(m["path"]),
Chapter: toString(m["chapter"]),
Duration: toFloat(m["duration"]),
})
}
}
if len(clips) < 2 {
return fmt.Errorf("need at least two clips to merge")
}
tmpDir := os.TempDir()
listFile, err := os.CreateTemp(tmpDir, "vt-merge-list-*.txt")
if err != nil {
return err
}
defer os.Remove(listFile.Name())
for _, c := range clips {
fmt.Fprintf(listFile, "file '%s'\n", strings.ReplaceAll(c.Path, "'", "'\\''"))
}
_ = listFile.Close()
// Build chapters metadata
chapterFile, err := os.CreateTemp(tmpDir, "vt-merge-chapters-*.txt")
if err != nil {
return err
}
var elapsed float64
fmt.Fprintln(chapterFile, ";FFMETADATA1")
for i, c := range clips {
startMs := int64(elapsed * 1000)
endMs := int64((elapsed + c.Duration) * 1000)
fmt.Fprintln(chapterFile, "[CHAPTER]")
fmt.Fprintln(chapterFile, "TIMEBASE=1/1000")
fmt.Fprintf(chapterFile, "START=%d\n", startMs)
fmt.Fprintf(chapterFile, "END=%d\n", endMs)
name := c.Chapter
if strings.TrimSpace(name) == "" {
name = fmt.Sprintf("Part %d", i+1)
}
fmt.Fprintf(chapterFile, "title=%s\n", name)
elapsed += c.Duration
}
_ = chapterFile.Close()
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-f", "concat",
"-safe", "0",
"-i", listFile.Name(),
"-i", chapterFile.Name(),
"-map_metadata", "1",
"-map_chapters", "1",
}
// Map streams
if keepAll {
args = append(args, "-map", "0")
} else {
args = append(args, "-map", "0:v:0", "-map", "0:a:0")
}
// Output profile
switch format {
case "dvd-ntsc-169", "dvd-ntsc-43", "dvd-pal-169", "dvd-pal-43":
// Force MPEG-2 / AC-3
args = append(args,
"-c:v", "mpeg2video",
"-c:a", "ac3",
"-b:a", "192k",
"-max_muxing_queue_size", "1024",
)
aspect := "16:9"
if strings.Contains(format, "43") {
aspect = "4:3"
}
if strings.Contains(format, "ntsc") {
args = append(args, "-vf", "scale=720:480,setsar=1", "-r", "30000/1001", "-pix_fmt", "yuv420p", "-aspect", aspect)
} else {
args = append(args, "-vf", "scale=720:576,setsar=1", "-r", "25", "-pix_fmt", "yuv420p", "-aspect", aspect)
}
args = append(args, "-target", "ntsc-dvd")
if strings.Contains(format, "pal") {
args[len(args)-1] = "pal-dvd"
}
case "bd-h264":
args = append(args,
"-c:v", "libx264",
"-preset", "slow",
"-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "ac3",
"-b:a", "256k",
)
default:
if codecMode == "copy" {
args = append(args, "-c", "copy")
} else {
// Re-encode to H.265 by default
args = append(args, "-c:v", "libx265", "-crf", "20", "-c:a", "copy")
}
}
args = append(args, outputPath)
// Execute
cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...)
utils.ApplyNoWindow(cmd)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if progressCallback != nil {
progressCallback(0)
}
err = cmd.Run()
if err != nil {
return fmt.Errorf("merge failed: %w\nFFmpeg output:\n%s", err, strings.TrimSpace(stderr.String()))
}
if progressCallback != nil {
progressCallback(100)
}
return nil
}
// executeConvertJob executes a conversion job from the queue
func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config