diff --git a/author_module.go b/author_module.go
index ad6c323..c569676 100644
--- a/author_module.go
+++ b/author_module.go
@@ -1,12 +1,18 @@
package main
import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
+ "sort"
"strings"
+ "time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
@@ -125,6 +131,8 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
+ state.authorChapters = nil
+ state.authorChapterSource = ""
rebuildList()
state.updateAuthorSummary()
})
@@ -139,6 +147,17 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
})
compileBtn.Importance = widget.HighImportance
+ chapterToggle := widget.NewCheck("Treat videos as chapters", func(checked bool) {
+ state.authorTreatAsChapters = checked
+ if checked {
+ state.authorChapterSource = "clips"
+ } else if state.authorChapterSource == "clips" {
+ state.authorChapterSource = ""
+ }
+ state.updateAuthorSummary()
+ })
+ chapterToggle.SetChecked(state.authorTreatAsChapters)
+
dropTarget := ui.NewDroppable(listScroll, func(items []fyne.URI) {
var paths []string
for _, uri := range items {
@@ -160,7 +179,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
controls := container.NewBorder(
widget.NewLabel("Videos:"),
- container.NewHBox(addBtn, clearBtn, compileBtn),
+ container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, compileBtn)),
nil,
nil,
listArea,
@@ -176,7 +195,23 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
fileLabel = widget.NewLabel(fmt.Sprintf("File: %s", filepath.Base(state.authorFile.Path)))
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
} else {
- fileLabel = widget.NewLabel("Select a single video file or use clips from Video Clips tab")
+ fileLabel = widget.NewLabel("Select a single video file or use clips from Videos tab")
+ }
+
+ chapterList := container.NewVBox()
+ refreshChapters := func() {
+ chapterList.Objects = nil
+ if len(state.authorChapters) == 0 {
+ chapterList.Add(widget.NewLabel("No chapters detected yet"))
+ return
+ }
+ for i, ch := range state.authorChapters {
+ title := ch.Title
+ if title == "" {
+ title = fmt.Sprintf("Chapter %d", i+1)
+ }
+ chapterList.Add(widget.NewLabel(fmt.Sprintf("%02d. %s (%s)", i+1, title, formatChapterTime(ch.Timestamp))))
+ }
}
selectBtn := widget.NewButton("Select Video", func() {
@@ -193,6 +228,8 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
}
state.authorFile = src
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(src.Path)))
+ state.loadEmbeddedChapters(path)
+ refreshChapters()
}, state.window)
})
@@ -206,18 +243,45 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
}
detectBtn := widget.NewButton("Detect Scenes", func() {
- if state.authorFile == nil && len(state.authorClips) == 0 {
+ targetPath := ""
+ if state.authorFile != nil {
+ targetPath = state.authorFile.Path
+ } else if len(state.authorClips) > 0 {
+ targetPath = state.authorClips[0].Path
+ }
+ if targetPath == "" {
dialog.ShowInformation("No File", "Please select a video file first", state.window)
return
}
- dialog.ShowInformation("Scene Detection", "Scene detection will be implemented", state.window)
+
+ progress := dialog.NewProgressInfinite("Scene Detection", "Analyzing scene changes with FFmpeg...", state.window)
+ progress.Show()
+ state.authorDetecting = true
+
+ go func() {
+ chapters, err := detectSceneChapters(targetPath, state.authorSceneThreshold)
+ runOnUI(func() {
+ progress.Hide()
+ state.authorDetecting = false
+ if err != nil {
+ dialog.ShowError(err, state.window)
+ return
+ }
+ if len(chapters) == 0 {
+ dialog.ShowInformation("Scene Detection", "No scene changes detected at the current sensitivity.", state.window)
+ return
+ }
+ state.authorChapters = chapters
+ state.authorChapterSource = "scenes"
+ state.updateAuthorSummary()
+ refreshChapters()
+ })
+ }()
})
detectBtn.Importance = widget.HighImportance
- chapterList := widget.NewLabel("No chapters detected yet")
-
addChapterBtn := widget.NewButton("+ Add Chapter", func() {
- dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented", state.window)
+ dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented.", state.window)
})
exportBtn := widget.NewButton("Export Chapters", func() {
@@ -238,6 +302,7 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
container.NewHBox(addChapterBtn, exportBtn),
)
+ refreshChapters()
return container.NewPadded(controls)
}
@@ -445,6 +510,10 @@ func authorSummary(state *appState) string {
}
}
+ if count, label := state.authorChapterSummary(); count > 0 {
+ summary += fmt.Sprintf("%s: %d\n", label, count)
+ }
+
summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType)
summary += fmt.Sprintf("Region: %s\n", state.authorRegion)
summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio)
@@ -455,6 +524,7 @@ func authorSummary(state *appState) string {
}
func (s *appState) addAuthorFiles(paths []string) {
+ wasEmpty := len(s.authorClips) == 0
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
@@ -470,6 +540,13 @@ func (s *appState) addAuthorFiles(paths []string) {
}
s.authorClips = append(s.authorClips, clip)
}
+
+ if wasEmpty && len(s.authorClips) == 1 {
+ s.loadEmbeddedChapters(s.authorClips[0].Path)
+ } else if len(s.authorClips) > 1 && s.authorChapterSource == "embedded" {
+ s.authorChapters = nil
+ s.authorChapterSource = ""
+ }
s.updateAuthorSummary()
}
@@ -480,6 +557,244 @@ func (s *appState) updateAuthorSummary() {
s.authorSummaryLabel.SetText(authorSummary(s))
}
+func (s *appState) authorChapterSummary() (int, string) {
+ if len(s.authorChapters) > 0 {
+ switch s.authorChapterSource {
+ case "embedded":
+ return len(s.authorChapters), "Embedded Chapters"
+ case "scenes":
+ return len(s.authorChapters), "Scene Chapters"
+ default:
+ return len(s.authorChapters), "Chapters"
+ }
+ }
+ if s.authorTreatAsChapters && len(s.authorClips) > 1 {
+ return len(s.authorClips), "Clip Chapters"
+ }
+ return 0, ""
+}
+
+func (s *appState) loadEmbeddedChapters(path string) {
+ chapters, err := extractChaptersFromFile(path)
+ if err != nil || len(chapters) == 0 {
+ if s.authorChapterSource == "embedded" {
+ s.authorChapters = nil
+ s.authorChapterSource = ""
+ s.updateAuthorSummary()
+ }
+ return
+ }
+ s.authorChapters = chapters
+ s.authorChapterSource = "embedded"
+ s.updateAuthorSummary()
+}
+
+func chaptersFromClips(clips []authorClip) []authorChapter {
+ if len(clips) == 0 {
+ return nil
+ }
+ var chapters []authorChapter
+ var t float64
+ chapters = append(chapters, authorChapter{Timestamp: 0, Title: "Chapter 1", Auto: true})
+ for i := 1; i < len(clips); i++ {
+ t += clips[i-1].Duration
+ chapters = append(chapters, authorChapter{
+ Timestamp: t,
+ Title: fmt.Sprintf("Chapter %d", i+1),
+ Auto: true,
+ })
+ }
+ return chapters
+}
+
+func detectSceneChapters(path string, threshold float64) ([]authorChapter, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ filter := fmt.Sprintf("select='gt(scene,%.2f)',showinfo", threshold)
+ cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath,
+ "-hide_banner",
+ "-loglevel", "info",
+ "-i", path,
+ "-vf", filter,
+ "-an",
+ "-f", "null",
+ "-",
+ )
+ utils.ApplyNoWindow(cmd)
+ out, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ times := map[float64]struct{}{}
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ for scanner.Scan() {
+ line := scanner.Text()
+ idx := strings.Index(line, "pts_time:")
+ if idx == -1 {
+ continue
+ }
+ rest := line[idx+len("pts_time:"):]
+ end := strings.IndexAny(rest, " ")
+ if end == -1 {
+ end = len(rest)
+ }
+ valStr := strings.TrimSpace(rest[:end])
+ if valStr == "" {
+ continue
+ }
+ if val, err := utils.ParseFloat(valStr); err == nil {
+ times[val] = struct{}{}
+ }
+ }
+
+ var vals []float64
+ for v := range times {
+ if v < 0.01 {
+ continue
+ }
+ vals = append(vals, v)
+ }
+ sort.Float64s(vals)
+
+ if len(vals) == 0 {
+ if err != nil {
+ return nil, fmt.Errorf("scene detection failed: %s", strings.TrimSpace(string(out)))
+ }
+ return nil, nil
+ }
+
+ chapters := []authorChapter{{Timestamp: 0, Title: "Chapter 1", Auto: true}}
+ for i, v := range vals {
+ chapters = append(chapters, authorChapter{
+ Timestamp: v,
+ Title: fmt.Sprintf("Chapter %d", i+2),
+ Auto: true,
+ })
+ }
+ return chapters, nil
+}
+
+func extractChaptersFromFile(path string) ([]authorChapter, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, platformConfig.FFprobePath,
+ "-v", "quiet",
+ "-print_format", "json",
+ "-show_chapters",
+ path,
+ )
+ utils.ApplyNoWindow(cmd)
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+
+ var result struct {
+ Chapters []struct {
+ StartTime string `json:"start_time"`
+ Tags map[string]interface{} `json:"tags"`
+ } `json:"chapters"`
+ }
+ if err := json.Unmarshal(out, &result); err != nil {
+ return nil, err
+ }
+
+ var chapters []authorChapter
+ for i, ch := range result.Chapters {
+ t, err := utils.ParseFloat(ch.StartTime)
+ if err != nil {
+ continue
+ }
+ title := ""
+ if ch.Tags != nil {
+ if v, ok := ch.Tags["title"]; ok {
+ title = fmt.Sprintf("%v", v)
+ }
+ }
+ if title == "" {
+ title = fmt.Sprintf("Chapter %d", i+1)
+ }
+ chapters = append(chapters, authorChapter{
+ Timestamp: t,
+ Title: title,
+ Auto: true,
+ })
+ }
+
+ return chapters, nil
+}
+
+func chaptersToDVDAuthor(chapters []authorChapter) string {
+ if len(chapters) == 0 {
+ return ""
+ }
+ var times []float64
+ for _, ch := range chapters {
+ if ch.Timestamp < 0 {
+ continue
+ }
+ times = append(times, ch.Timestamp)
+ }
+ if len(times) == 0 {
+ return ""
+ }
+ sort.Float64s(times)
+ if times[0] > 0.01 {
+ times = append([]float64{0}, times...)
+ }
+ seen := map[int]struct{}{}
+ var parts []string
+ for _, t := range times {
+ key := int(t * 1000)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ parts = append(parts, formatChapterTime(t))
+ }
+ return strings.Join(parts, ",")
+}
+
+func formatChapterTime(sec float64) string {
+ if sec < 0 {
+ sec = 0
+ }
+ d := time.Duration(sec * float64(time.Second))
+ h := int(d.Hours())
+ m := int(d.Minutes()) % 60
+ s := int(d.Seconds()) % 60
+ return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
+}
+
+func concatDVDMpg(inputs []string, output string) error {
+ listPath := filepath.Join(filepath.Dir(output), "concat_list.txt")
+ listFile, err := os.Create(listPath)
+ if err != nil {
+ return fmt.Errorf("failed to create concat list: %w", err)
+ }
+ for _, path := range inputs {
+ fmt.Fprintf(listFile, "file '%s'\n", strings.ReplaceAll(path, "'", "'\\''"))
+ }
+ if err := listFile.Close(); err != nil {
+ return fmt.Errorf("failed to write concat list: %w", err)
+ }
+ defer os.Remove(listPath)
+
+ args := []string{
+ "-hide_banner",
+ "-loglevel", "error",
+ "-f", "concat",
+ "-safe", "0",
+ "-i", listPath,
+ "-c", "copy",
+ output,
+ }
+ return runCommand(platformConfig.FFmpegPath, args)
+}
+
func (s *appState) startAuthorGeneration() {
paths, primary, err := s.authorSourcePaths()
if err != nil {
@@ -691,8 +1006,32 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o
return err
}
+ chapters := s.authorChapters
+ if len(chapters) == 0 && s.authorTreatAsChapters && len(s.authorClips) > 1 {
+ chapters = chaptersFromClips(s.authorClips)
+ s.authorChapterSource = "clips"
+ }
+ if len(chapters) == 0 && len(mpgPaths) == 1 {
+ if embed, err := extractChaptersFromFile(paths[0]); err == nil && len(embed) > 0 {
+ chapters = embed
+ s.authorChapterSource = "embedded"
+ }
+ }
+
+ if s.authorTreatAsChapters && len(mpgPaths) > 1 {
+ concatPath := filepath.Join(workDir, "titles_joined.mpg")
+ if err := concatDVDMpg(mpgPaths, concatPath); err != nil {
+ return err
+ }
+ mpgPaths = []string{concatPath}
+ }
+
+ if len(mpgPaths) > 1 {
+ chapters = nil
+ }
+
xmlPath := filepath.Join(workDir, "dvd.xml")
- if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect); err != nil {
+ if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters); err != nil {
return err
}
@@ -808,7 +1147,7 @@ func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progres
return args
}
-func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string) error {
+func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, chapters []authorChapter) error {
format := strings.ToLower(region)
if format != "pal" {
format = "ntsc"
@@ -822,7 +1161,11 @@ func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string) er
b.WriteString(fmt.Sprintf(" \n", format, aspect))
for _, mpg := range mpgPaths {
b.WriteString(" \n")
- b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(mpg)))
+ if len(chapters) > 0 {
+ b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(mpg), chaptersToDVDAuthor(chapters)))
+ } else {
+ b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(mpg)))
+ }
b.WriteString(" \n")
}
b.WriteString(" \n")
diff --git a/main.go b/main.go
index 152c543..71e28b4 100644
--- a/main.go
+++ b/main.go
@@ -903,19 +903,21 @@ type appState struct {
sidebarVisible bool
// Author module state
- authorFile *videoSource
- authorChapters []authorChapter
- authorSceneThreshold float64
- authorDetecting bool
- authorClips []authorClip // Multiple video clips for compilation
- authorOutputType string // "dvd" or "iso"
- authorRegion string // "NTSC", "PAL", "AUTO"
- authorAspectRatio string // "4:3", "16:9", "AUTO"
- authorCreateMenu bool // Whether to create DVD menu
- authorTitle string // DVD title
- authorSubtitles []string // Subtitle file paths
- authorAudioTracks []string // Additional audio tracks
- authorSummaryLabel *widget.Label
+ authorFile *videoSource
+ authorChapters []authorChapter
+ authorSceneThreshold float64
+ authorDetecting bool
+ authorClips []authorClip // Multiple video clips for compilation
+ authorOutputType string // "dvd" or "iso"
+ authorRegion string // "NTSC", "PAL", "AUTO"
+ authorAspectRatio string // "4:3", "16:9", "AUTO"
+ authorCreateMenu bool // Whether to create DVD menu
+ authorTitle string // DVD title
+ authorSubtitles []string // Subtitle file paths
+ authorAudioTracks []string // Additional audio tracks
+ authorSummaryLabel *widget.Label
+ authorTreatAsChapters bool // Treat multiple clips as chapters
+ authorChapterSource string // embedded, scenes, clips, manual
}
type mergeClip struct {