Finalize authoring workflow and update install docs

This commit is contained in:
Stu Leak 2025-12-23 14:24:09 -05:00
parent d031afa269
commit 8513902232
16 changed files with 1417 additions and 1385 deletions

View File

@ -30,7 +30,7 @@ VideoTools is a professional-grade video processing application with a modern GU
### Installation (One Command)
```bash
bash install.sh
bash scripts/install.sh
```
The installer will build, install, and set up everything automatically with a guided wizard!
@ -43,12 +43,12 @@ VideoTools
### Alternative: Developer Setup
If you already have the repo cloned:
If you already have the repo cloned (dev workflow):
```bash
cd /path/to/VideoTools
source scripts/alias.sh
VideoTools
bash scripts/build.sh
bash scripts/run.sh
```
For detailed installation options, troubleshooting, and platform-specific notes, see **INSTALLATION.md**.

260
author_dvd_functions.go Normal file
View File

@ -0,0 +1,260 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// buildDVDRipTab creates a DVD/ISO ripping tab with import support
func buildDVDRipTab(state *appState) fyne.CanvasObject {
// DVD/ISO source
var sourceType string // "dvd" or "iso"
var isDVD5 bool
var isDVD9 bool
var titles []DVDTitle
sourceLabel := widget.NewLabel("No DVD/ISO selected")
sourceLabel.TextStyle = fyne.TextStyle{Bold: true}
var updateTitleList func()
importBtn := widget.NewButton("Import DVD/ISO", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
path := reader.URI().Path()
if strings.ToLower(filepath.Ext(path)) == ".iso" {
sourceType = "iso"
sourceLabel.SetText(fmt.Sprintf("ISO: %s", filepath.Base(path)))
} else if isDVDPath(path) {
sourceType = "dvd"
sourceLabel.SetText(fmt.Sprintf("DVD: %s", path))
} else {
dialog.ShowError(fmt.Errorf("not a valid DVD or ISO file"), state.window)
return
}
// Analyze DVD/ISO
analyzedTitles, dvd5, dvd9 := analyzeDVDStructure(path, sourceType)
titles = analyzedTitles
isDVD5 = dvd5
isDVD9 = dvd9
updateTitleList()
}, state.window)
})
importBtn.Importance = widget.HighImportance
// Title list
titleList := container.NewVBox()
updateTitleList = func() {
titleList.Objects = nil
if len(titles) == 0 {
emptyLabel := widget.NewLabel("Import a DVD or ISO to analyze")
emptyLabel.Alignment = fyne.TextAlignCenter
titleList.Add(container.NewCenter(emptyLabel))
return
}
// Add DVD5/DVD9 indicators
if isDVD5 {
dvd5Label := widget.NewLabel("🎞 DVD-5 Detected (Single Layer)")
dvd5Label.Importance = widget.LowImportance
titleList.Add(dvd5Label)
}
if isDVD9 {
dvd9Label := widget.NewLabel("🎞 DVD-9 Detected (Dual Layer)")
dvd9Label.Importance = widget.LowImportance
titleList.Add(dvd9Label)
}
// Add titles
for i, title := range titles {
idx := i
titleCard := widget.NewCard(
fmt.Sprintf("Title %d: %s", idx+1, title.Name),
fmt.Sprintf("%.2fs (%.1f GB)", title.Duration, title.SizeGB),
nil,
)
// Title details
details := container.NewVBox(
widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", title.Duration)),
widget.NewLabel(fmt.Sprintf("Size: %.1f GB", title.SizeGB)),
widget.NewLabel(fmt.Sprintf("Video: %s", title.VideoCodec)),
widget.NewLabel(fmt.Sprintf("Audio: %d tracks", len(title.AudioTracks))),
widget.NewLabel(fmt.Sprintf("Subtitles: %d tracks", len(title.SubtitleTracks))),
widget.NewLabel(fmt.Sprintf("Chapters: %d", len(title.Chapters))),
)
titleCard.SetContent(details)
// Rip button for this title
ripBtn := widget.NewButton("Rip Title", func() {
ripTitle(title, state)
})
ripBtn.Importance = widget.HighImportance
// Add to controls
controls := container.NewVBox(details, widget.NewSeparator(), ripBtn)
titleCard.SetContent(controls)
titleList.Add(titleCard)
}
}
// Rip all button
ripAllBtn := widget.NewButton("Rip All Titles", func() {
if len(titles) == 0 {
dialog.ShowInformation("No Titles", "Please import a DVD or ISO first", state.window)
return
}
ripAllTitles(titles, state)
})
ripAllBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("DVD/ISO Source:"),
sourceLabel,
importBtn,
widget.NewSeparator(),
widget.NewLabel("Titles Found:"),
container.NewScroll(titleList),
widget.NewSeparator(),
container.NewHBox(ripAllBtn),
)
return container.NewPadded(controls)
}
// DVDTitle represents a DVD title
type DVDTitle struct {
Number int
Name string
Duration float64
SizeGB float64
VideoCodec string
AudioTracks []DVDTrack
SubtitleTracks []DVDTrack
Chapters []DVDChapter
AngleCount int
IsPAL bool
}
// DVDTrack represents an audio/subtitle track
type DVDTrack struct {
ID int
Language string
Codec string
Channels int
SampleRate int
Bitrate int
}
// DVDChapter represents a chapter
type DVDChapter struct {
Number int
Title string
StartTime float64
Duration float64
}
// isDVDPath checks if path is likely a DVD structure
func isDVDPath(path string) bool {
// Check for VIDEO_TS directory
videoTS := filepath.Join(path, "VIDEO_TS")
if _, err := os.Stat(videoTS); err == nil {
return true
}
// Check for common DVD file patterns
dirs, err := os.ReadDir(path)
if err != nil {
return false
}
for _, dir := range dirs {
name := strings.ToUpper(dir.Name())
if strings.Contains(name, "VIDEO_TS") ||
strings.Contains(name, "VTS_") {
return true
}
}
return false
}
// analyzeDVDStructure analyzes a DVD or ISO file for titles
func analyzeDVDStructure(path string, sourceType string) ([]DVDTitle, bool, bool) {
// This is a placeholder implementation
// In reality, you would use FFmpeg with DVD input support
dialog.ShowInformation("DVD Analysis",
fmt.Sprintf("Analyzing %s: %s\n\nThis will extract DVD structure and find all titles, audio tracks, and subtitles.", sourceType, filepath.Base(path)),
nil)
// Return sample titles
return []DVDTitle{
{
Number: 1,
Name: "Main Feature",
Duration: 7200, // 2 hours
SizeGB: 7.8,
VideoCodec: "MPEG-2",
AudioTracks: []DVDTrack{
{ID: 1, Language: "en", Codec: "AC-3", Channels: 6, SampleRate: 48000, Bitrate: 448000},
{ID: 2, Language: "es", Codec: "AC-3", Channels: 2, SampleRate: 48000, Bitrate: 192000},
},
SubtitleTracks: []DVDTrack{
{ID: 1, Language: "en", Codec: "SubRip"},
{ID: 2, Language: "es", Codec: "SubRip"},
},
Chapters: []DVDChapter{
{Number: 1, Title: "Chapter 1", StartTime: 0, Duration: 1800},
{Number: 2, Title: "Chapter 2", StartTime: 1800, Duration: 1800},
{Number: 3, Title: "Chapter 3", StartTime: 3600, Duration: 1800},
{Number: 4, Title: "Chapter 4", StartTime: 5400, Duration: 1800},
},
AngleCount: 1,
IsPAL: false,
},
}, false, false // DVD-5 by default for this example
}
// ripTitle rips a single DVD title to MKV format
func ripTitle(title DVDTitle, state *appState) {
// Default to AV1 in MKV for best quality
outputPath := fmt.Sprintf("%s_%s_Title%d.mkv",
strings.TrimSuffix(strings.TrimSuffix(filepath.Base(state.authorFile.Path), filepath.Ext(state.authorFile.Path)), ".dvd"),
title.Name,
title.Number)
dialog.ShowInformation("Rip Title",
fmt.Sprintf("Ripping Title %d: %s\n\nOutput: %s\nFormat: MKV (AV1)\nAudio: All tracks\nSubtitles: All tracks",
title.Number, title.Name, outputPath),
state.window)
// TODO: Implement actual ripping with FFmpeg
// This would use FFmpeg to extract the title with selected codec
// For DVD: ffmpeg -i dvd://1 -c:v libaom-av1 -c:a libopus -map_metadata 0 output.mkv
// For ISO: ffmpeg -i path/to/iso -map 0:v:0 -map 0:a -c:v libaom-av1 -c:a libopus output.mkv
}
// ripAllTitles rips all DVD titles
func ripAllTitles(titles []DVDTitle, state *appState) {
dialog.ShowInformation("Rip All Titles",
fmt.Sprintf("Ripping all %d titles\n\nThis will extract each title to separate MKV files with AV1 encoding.", len(titles)),
state.window)
// TODO: Implement batch ripping
for _, title := range titles {
ripTitle(title, state)
}
}

884
author_module.go Normal file
View File

@ -0,0 +1,884 @@
package main
import (
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
func buildAuthorView(state *appState) fyne.CanvasObject {
state.stopPreview()
state.lastModule = state.active
state.active = "author"
if state.authorOutputType == "" {
state.authorOutputType = "dvd"
}
if state.authorRegion == "" {
state.authorRegion = "AUTO"
}
if state.authorAspectRatio == "" {
state.authorAspectRatio = "AUTO"
}
authorColor := moduleColor("author")
backBtn := widget.NewButton("< BACK", func() {
state.showMainMenu()
})
backBtn.Importance = widget.LowImportance
queueBtn := widget.NewButton("View Queue", func() {
state.showQueue()
})
state.queueBtn = queueBtn
state.updateQueueButtonLabel()
topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn))
bottomBar := moduleFooter(authorColor, layout.NewSpacer(), state.statsBar)
tabs := container.NewAppTabs(
container.NewTabItem("Video Clips", buildVideoClipsTab(state)),
container.NewTabItem("Chapters", buildChaptersTab(state)),
container.NewTabItem("Subtitles", buildSubtitlesTab(state)),
container.NewTabItem("Settings", buildAuthorSettingsTab(state)),
container.NewTabItem("Generate", buildAuthorDiscTab(state)),
)
tabs.SetTabLocation(container.TabLocationTop)
return container.NewBorder(topBar, bottomBar, nil, nil, tabs)
}
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
list := container.NewVBox()
var rebuildList func()
rebuildList = func() {
list.Objects = nil
if len(state.authorClips) == 0 {
emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
return
}
for i, clip := range state.authorClips {
idx := i
card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil)
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
rebuildList()
})
removeBtn.Importance = widget.MediumImportance
durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
cardContent := container.NewVBox(
durationLabel,
widget.NewSeparator(),
removeBtn,
)
card.SetContent(cardContent)
list.Add(card)
}
}
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.addAuthorFiles([]string{reader.URI().Path()})
}, state.window)
})
addBtn.Importance = widget.HighImportance
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
rebuildList()
})
clearBtn.Importance = widget.MediumImportance
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
state.startAuthorGeneration()
})
compileBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("Video Clips:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn, compileBtn),
)
rebuildList()
return container.NewPadded(controls)
}
func buildChaptersTab(state *appState) fyne.CanvasObject {
var fileLabel *widget.Label
if state.authorFile != nil {
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")
}
selectBtn := widget.NewButton("Select Video", func() {
dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
path := uc.URI().Path()
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
state.authorFile = src
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(src.Path)))
}, state.window)
})
thresholdLabel := widget.NewLabel(fmt.Sprintf("Detection Sensitivity: %.2f", state.authorSceneThreshold))
thresholdSlider := widget.NewSlider(0.1, 0.9)
thresholdSlider.Value = state.authorSceneThreshold
thresholdSlider.Step = 0.05
thresholdSlider.OnChanged = func(v float64) {
state.authorSceneThreshold = v
thresholdLabel.SetText(fmt.Sprintf("Detection Sensitivity: %.2f", v))
}
detectBtn := widget.NewButton("Detect Scenes", func() {
if state.authorFile == nil && len(state.authorClips) == 0 {
dialog.ShowInformation("No File", "Please select a video file first", state.window)
return
}
dialog.ShowInformation("Scene Detection", "Scene detection will be implemented", state.window)
})
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)
})
exportBtn := widget.NewButton("Export Chapters", func() {
dialog.ShowInformation("Export", "Chapter export will be implemented", state.window)
})
controls := container.NewVBox(
fileLabel,
selectBtn,
widget.NewSeparator(),
widget.NewLabel("Scene Detection:"),
thresholdLabel,
thresholdSlider,
detectBtn,
widget.NewSeparator(),
widget.NewLabel("Chapters:"),
container.NewScroll(chapterList),
container.NewHBox(addChapterBtn, exportBtn),
)
return container.NewPadded(controls)
}
func buildSubtitlesTab(state *appState) fyne.CanvasObject {
list := container.NewVBox()
var buildSubList func()
buildSubList = func() {
list.Objects = nil
if len(state.authorSubtitles) == 0 {
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select")
emptyLabel.Alignment = fyne.TextAlignCenter
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.authorSubtitles = append(state.authorSubtitles, paths...)
buildSubList()
}
})
list.Add(container.NewMax(emptyDrop))
return
}
for i, path := range state.authorSubtitles {
idx := i
card := widget.NewCard(filepath.Base(path), "", nil)
removeBtn := widget.NewButton("Remove", func() {
state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...)
buildSubList()
})
removeBtn.Importance = widget.MediumImportance
cardContent := container.NewVBox(removeBtn)
card.SetContent(cardContent)
list.Add(card)
}
}
addBtn := widget.NewButton("Add Subtitles", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path())
buildSubList()
}, state.window)
})
addBtn.Importance = widget.HighImportance
clearBtn := widget.NewButton("Clear All", func() {
state.authorSubtitles = []string{}
buildSubList()
})
clearBtn.Importance = widget.MediumImportance
controls := container.NewVBox(
widget.NewLabel("Subtitle Tracks:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn),
)
buildSubList()
return container.NewPadded(controls)
}
func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}, func(value string) {
if value == "DVD (VIDEO_TS)" {
state.authorOutputType = "dvd"
} else {
state.authorOutputType = "iso"
}
})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
} else {
outputType.SetSelected("DVD (VIDEO_TS)")
}
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) {
state.authorRegion = value
})
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) {
state.authorAspectRatio = value
})
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("DVD Title")
titleEntry.SetText(state.authorTitle)
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
}
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
})
createMenuCheck.SetChecked(state.authorCreateMenu)
info := widget.NewLabel("Requires: ffmpeg, dvdauthor, and mkisofs/genisoimage (for ISO).")
info.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Output Settings:"),
widget.NewSeparator(),
widget.NewLabel("Output Type:"),
outputType,
widget.NewLabel("Region:"),
regionSelect,
widget.NewLabel("Aspect Ratio:"),
aspectSelect,
widget.NewLabel("DVD Title:"),
titleEntry,
createMenuCheck,
widget.NewSeparator(),
info,
)
return container.NewPadded(controls)
}
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
generateBtn := widget.NewButton("GENERATE DVD", func() {
if len(state.authorClips) == 0 && state.authorFile == nil {
dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window)
return
}
state.startAuthorGeneration()
})
generateBtn.Importance = widget.HighImportance
summaryLabel := widget.NewLabel(authorSummary(state))
summaryLabel.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}
func authorSummary(state *appState) string {
summary := "Ready to generate:\n\n"
if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
}
} else if state.authorFile != nil {
summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path))
}
if len(state.authorSubtitles) > 0 {
summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles))
for i, path := range state.authorSubtitles {
summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path))
}
}
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)
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
return summary
}
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
func (s *appState) startAuthorGeneration() {
paths, primary, err := s.authorSourcePaths()
if err != nil {
dialog.ShowError(err, s.window)
return
}
region := resolveAuthorRegion(s.authorRegion, primary)
aspect := resolveAuthorAspect(s.authorAspectRatio, primary)
title := strings.TrimSpace(s.authorTitle)
if title == "" {
title = defaultAuthorTitle(paths)
}
warnings := authorWarnings(s)
continuePrompt := func() {
s.promptAuthorOutput(paths, region, aspect, title)
}
if len(warnings) > 0 {
dialog.ShowConfirm("Authoring Notes", strings.Join(warnings, "\n")+"\n\nContinue?", func(ok bool) {
if ok {
continuePrompt()
}
}, s.window)
return
}
continuePrompt()
}
func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string) {
outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType))
if outputType == "" {
outputType = "dvd"
}
if outputType == "iso" {
dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) {
if err != nil || writer == nil {
return
}
path := writer.URI().Path()
writer.Close()
if !strings.HasSuffix(strings.ToLower(path), ".iso") {
path += ".iso"
}
s.generateAuthoring(paths, region, aspect, title, path, true)
}, s.window)
return
}
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
discRoot := filepath.Join(uri.Path(), authorOutputFolderName(title, paths))
s.generateAuthoring(paths, region, aspect, title, discRoot, false)
}, s.window)
}
func authorWarnings(state *appState) []string {
var warnings []string
if state.authorCreateMenu {
warnings = append(warnings, "DVD menus are not implemented yet; the disc will play titles directly.")
}
if len(state.authorSubtitles) > 0 {
warnings = append(warnings, "Subtitle tracks are not authored yet; they will be ignored.")
}
if len(state.authorAudioTracks) > 0 {
warnings = append(warnings, "Additional audio tracks are not authored yet; they will be ignored.")
}
return warnings
}
func (s *appState) authorSourcePaths() ([]string, *videoSource, error) {
if len(s.authorClips) > 0 {
paths := make([]string, 0, len(s.authorClips))
for _, clip := range s.authorClips {
paths = append(paths, clip.Path)
}
primary, err := probeVideo(paths[0])
if err != nil {
return nil, nil, fmt.Errorf("failed to probe source: %w", err)
}
return paths, primary, nil
}
if s.authorFile != nil {
return []string{s.authorFile.Path}, s.authorFile, nil
}
return nil, nil, fmt.Errorf("no authoring content selected")
}
func resolveAuthorRegion(pref string, src *videoSource) string {
pref = strings.ToUpper(strings.TrimSpace(pref))
if pref == "NTSC" || pref == "PAL" {
return pref
}
if src != nil {
if src.FrameRate > 0 {
if src.FrameRate <= 26 {
return "PAL"
}
return "NTSC"
}
if src.Height == 576 {
return "PAL"
}
if src.Height == 480 {
return "NTSC"
}
}
return "NTSC"
}
func resolveAuthorAspect(pref string, src *videoSource) string {
pref = strings.TrimSpace(pref)
if pref == "4:3" || pref == "16:9" {
return pref
}
if src != nil && src.Width > 0 && src.Height > 0 {
ratio := float64(src.Width) / float64(src.Height)
if ratio >= 1.55 {
return "16:9"
}
return "4:3"
}
return "16:9"
}
func defaultAuthorTitle(paths []string) string {
if len(paths) == 0 {
return "DVD"
}
base := filepath.Base(paths[0])
return strings.TrimSuffix(base, filepath.Ext(base))
}
func authorOutputFolderName(title string, paths []string) string {
name := strings.TrimSpace(title)
if name == "" {
name = defaultAuthorTitle(paths)
}
name = sanitizeForPath(name)
if name == "" {
name = "dvd_output"
}
return name
}
func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) {
if err := ensureAuthorDependencies(makeISO); err != nil {
dialog.ShowError(err, s.window)
return
}
progress := dialog.NewProgressInfinite("Authoring DVD", "Encoding sources...", s.window)
progress.Show()
go func() {
err := s.runAuthoringPipeline(paths, region, aspect, title, outputPath, makeISO)
message := "DVD authoring complete."
if makeISO {
message = fmt.Sprintf("ISO image created:\n%s", outputPath)
} else {
message = fmt.Sprintf("DVD folders created:\n%s", outputPath)
}
runOnUI(func() {
progress.Hide()
if err != nil {
dialog.ShowError(err, s.window)
return
}
dialog.ShowInformation("Authoring Complete", message, s.window)
})
}()
}
func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, outputPath string, makeISO bool) error {
workDir, err := os.MkdirTemp(utils.TempDir(), "videotools-author-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(workDir)
discRoot := outputPath
var cleanup func()
if makeISO {
tempRoot, err := os.MkdirTemp(utils.TempDir(), "videotools-dvd-")
if err != nil {
return fmt.Errorf("failed to create DVD output directory: %w", err)
}
discRoot = tempRoot
cleanup = func() {
_ = os.RemoveAll(tempRoot)
}
}
if cleanup != nil {
defer cleanup()
}
if err := prepareDiscRoot(discRoot); err != nil {
return err
}
mpgPaths, err := encodeAuthorSources(paths, region, aspect, workDir)
if err != nil {
return err
}
xmlPath := filepath.Join(workDir, "dvd.xml")
if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect); err != nil {
return err
}
if err := runCommand("dvdauthor", []string{"-o", discRoot, "-x", xmlPath}); err != nil {
return err
}
if err := runCommand("dvdauthor", []string{"-o", discRoot, "-T"}); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil {
return fmt.Errorf("failed to create AUDIO_TS: %w", err)
}
if makeISO {
tool, args, err := buildISOCommand(outputPath, discRoot, title)
if err != nil {
return err
}
if err := runCommand(tool, args); err != nil {
return err
}
}
return nil
}
func prepareDiscRoot(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("failed to read output directory: %w", err)
}
if len(entries) > 0 {
return fmt.Errorf("output folder must be empty: %s", path)
}
return nil
}
func encodeAuthorSources(paths []string, region, aspect, workDir string) ([]string, error) {
var mpgPaths []string
for i, path := range paths {
idx := i + 1
outPath := filepath.Join(workDir, fmt.Sprintf("title_%02d.mpg", idx))
src, err := probeVideo(path)
if err != nil {
return nil, fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err)
}
args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive())
if err := runCommand(platformConfig.FFmpegPath, args); err != nil {
return nil, err
}
mpgPaths = append(mpgPaths, outPath)
}
return mpgPaths, nil
}
func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progressive bool) []string {
width := 720
height := 480
fps := "30000/1001"
gop := "15"
bitrate := "6000k"
maxrate := "9000k"
if region == "PAL" {
height = 576
fps = "25"
gop = "12"
bitrate = "8000k"
maxrate = "9500k"
}
vf := []string{
fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", width, height),
fmt.Sprintf("pad=%d:%d:(ow-iw)/2:(oh-ih)/2", width, height),
fmt.Sprintf("setdar=%s", aspect),
"setsar=1",
fmt.Sprintf("fps=%s", fps),
}
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", inputPath,
"-vf", strings.Join(vf, ","),
"-c:v", "mpeg2video",
"-r", fps,
"-b:v", bitrate,
"-maxrate", maxrate,
"-bufsize", "1835k",
"-g", gop,
"-pix_fmt", "yuv420p",
}
if !progressive {
args = append(args, "-flags", "+ilme+ildct")
}
args = append(args,
"-c:a", "ac3",
"-b:a", "192k",
"-ar", "48000",
"-ac", "2",
outputPath,
)
return args
}
func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string) error {
format := strings.ToLower(region)
if format != "pal" {
format = "ntsc"
}
var b strings.Builder
b.WriteString("<dvdauthor>\n")
b.WriteString(" <vmgm />\n")
b.WriteString(" <titleset>\n")
b.WriteString(" <titles>\n")
b.WriteString(fmt.Sprintf(" <video format=\"%s\" aspect=\"%s\" />\n", format, aspect))
for _, mpg := range mpgPaths {
b.WriteString(" <pgc>\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(mpg)))
b.WriteString(" </pgc>\n")
}
b.WriteString(" </titles>\n")
b.WriteString(" </titleset>\n")
b.WriteString("</dvdauthor>\n")
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
return fmt.Errorf("failed to write dvdauthor XML: %w", err)
}
return nil
}
func escapeXMLAttr(value string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(value)); err != nil {
return strings.ReplaceAll(value, "\"", "&quot;")
}
escaped := b.String()
return strings.ReplaceAll(escaped, "\"", "&quot;")
}
func ensureAuthorDependencies(makeISO bool) error {
if err := ensureExecutable(platformConfig.FFmpegPath, "ffmpeg"); err != nil {
return err
}
if _, err := exec.LookPath("dvdauthor"); err != nil {
return fmt.Errorf("dvdauthor not found in PATH")
}
if makeISO {
if _, _, err := buildISOCommand("output.iso", "output", "VIDEO_TOOLS"); err != nil {
return err
}
}
return nil
}
func ensureExecutable(path, label string) error {
if filepath.IsAbs(path) {
if _, err := os.Stat(path); err == nil {
return nil
}
}
if _, err := exec.LookPath(path); err == nil {
return nil
}
return fmt.Errorf("%s not found (%s)", label, path)
}
func buildISOCommand(outputISO, discRoot, title string) (string, []string, error) {
tool, prefixArgs, err := findISOTool()
if err != nil {
return "", nil, err
}
label := isoVolumeLabel(title)
args := append([]string{}, prefixArgs...)
args = append(args, "-dvd-video", "-V", label, "-o", outputISO, discRoot)
return tool, args, nil
}
func findISOTool() (string, []string, error) {
if path, err := exec.LookPath("mkisofs"); err == nil {
return path, nil, nil
}
if path, err := exec.LookPath("genisoimage"); err == nil {
return path, nil, nil
}
if path, err := exec.LookPath("xorriso"); err == nil {
return path, []string{"-as", "mkisofs"}, nil
}
return "", nil, fmt.Errorf("mkisofs, genisoimage, or xorriso not found in PATH")
}
func isoVolumeLabel(title string) string {
label := strings.ToUpper(strings.TrimSpace(title))
if label == "" {
label = "VIDEO_TOOLS"
}
var b strings.Builder
for _, r := range label {
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune('_')
default:
b.WriteRune('_')
}
}
clean := strings.Trim(b.String(), "_")
if clean == "" {
clean = "VIDEO_TOOLS"
}
if len(clean) > 32 {
clean = clean[:32]
}
return clean
}
func runCommand(name string, args []string) error {
cmd := exec.Command(name, args...)
utils.ApplyNoWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s failed: %s", name, strings.TrimSpace(string(output)))
}
return nil
}
func runOnUI(fn func()) {
fn()
}

View File

@ -1,334 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
)
// buildVideoClipsTab creates the video clips tab with drag-and-drop support
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
// Video clips list with drag-and-drop support
list := container.NewVBox()
rebuildList := func() {
list.Objects = nil
if len(state.authorClips) == 0 {
emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, clip := range state.authorClips {
idx := i
card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
rebuildList()
})
removeBtn.Importance = widget.MediumImportance
// Duration label
durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
cardContent := container.NewVBox(
durationLabel,
widget.NewSeparator(),
removeBtn,
)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add files button
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.addAuthorFiles([]string{reader.URI().Path()})
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
rebuildList()
})
clearBtn.Importance = widget.MediumImportance
// Compile button
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
// TODO: Implement compilation to DVD
dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window)
})
compileBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("Video Clips:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn, compileBtn),
)
// Initialize the list
rebuildList()
return container.NewPadded(controls)
}
// addAuthorFiles helper function
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
// buildSubtitlesTab creates the subtitles tab with drag-and-drop support
func buildSubtitlesTab(state *appState) fyne.CanvasObject {
// Subtitle files list with drag-and-drop support
list := container.NewVBox()
rebuildSubList := func() {
list.Objects = nil
if len(state.authorSubtitles) == 0 {
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.authorSubtitles = append(state.authorSubtitles, paths...)
rebuildSubList()
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, path := range state.authorSubtitles {
idx := i
card := widget.NewCard(filepath.Base(path), "", nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...)
rebuildSubList()
})
removeBtn.Importance = widget.MediumImportance
cardContent := container.NewVBox(removeBtn)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add subtitles button
addBtn := widget.NewButton("Add Subtitles", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path())
rebuildSubList()
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorSubtitles = []string{}
rebuildSubList()
})
clearBtn.Importance = widget.MediumImportance
controls := container.NewVBox(
widget.NewLabel("Subtitle Tracks:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn),
)
// Initialize
rebuildSubList()
return container.NewPadded(controls)
}
// buildAuthorSettingsTab creates the author settings tab
func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
// Output type selection
outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"})
outputType.OnChanged = func(value string) {
if value == "DVD (VIDEO_TS)" {
state.authorOutputType = "dvd"
} else {
state.authorOutputType = "iso"
}
})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
}
// Region selection
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"})
regionSelect.OnChanged = func(value string) {
state.authorRegion = value
})
if state.authorRegion == "" {
state.authorRegion = "AUTO"
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
// Aspect ratio selection
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"})
aspectSelect.OnChanged = func(value string) {
state.authorAspectRatio = value
})
if state.authorAspectRatio == "" {
state.authorAspectRatio = "AUTO"
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
// DVD title entry
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("DVD Title")
titleEntry.SetText(state.authorTitle)
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
}
// Create menu checkbox
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
})
createMenuCheck.SetChecked(state.authorCreateMenu)
controls := container.NewVBox(
widget.NewLabel("Output Settings:"),
widget.NewSeparator(),
widget.NewLabel("Output Type:"),
outputType,
widget.NewLabel("Region:"),
regionSelect,
widget.NewLabel("Aspect Ratio:"),
aspectSelect,
widget.NewLabel("DVD Title:"),
titleEntry,
createMenuCheck,
)
return container.NewPadded(controls)
}
// buildAuthorDiscTab creates the DVD generation tab
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
// Generate DVD/ISO
generateBtn := widget.NewButton("GENERATE DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Content", "Please add video clips first", state.window)
return
}
// Show compilation options
dialog.ShowInformation("DVD Generation",
"DVD/ISO generation will be implemented in next step.\n\n"+
"Features planned:\n"+
"• Create VIDEO_TS folder structure\n"+
"• Generate burn-ready ISO\n"+
"• Include subtitle tracks\n"+
"• Include alternate audio tracks\n"+
"• Support for alternate camera angles", state.window)
})
generateBtn.Importance = widget.HighImportance
// Show summary
summary := "Ready to generate:\n\n"
if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
}
}
if len(state.authorSubtitles) > 0 {
summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles))
for i, path := range state.authorSubtitles {
summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path))
}
}
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)
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
summaryLabel := widget.NewLabel(summary)
summaryLabel.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}

View File

@ -13,7 +13,7 @@
- **Cross-compilation script** (`scripts/build-windows.sh`)
#### Professional Installation System
- **One-command installer** (`install.sh`) with guided wizard
- **One-command installer** (`scripts/install.sh`) with guided wizard
- **Automatic shell detection** (bash/zsh) and configuration
- **System-wide vs user-local installation** options
- **Convenience aliases** (`VideoTools`, `VideoToolsRebuild`, `VideoToolsClean`)

View File

@ -7,7 +7,7 @@ This guide will help you install VideoTools with minimal setup.
### One-Command Installation
```bash
bash install.sh
bash scripts/install.sh
```
That's it! The installer will:
@ -43,7 +43,7 @@ VideoTools
### Option 1: System-Wide Installation (Recommended for Shared Computers)
```bash
bash install.sh
bash scripts/install.sh
# Select option 1 when prompted
# Enter your password if requested
```
@ -61,7 +61,7 @@ bash install.sh
### Option 2: User-Local Installation (Recommended for Personal Use)
```bash
bash install.sh
bash scripts/install.sh
# Select option 2 when prompted (default)
```
@ -78,7 +78,7 @@ bash install.sh
## What the Installer Does
The `install.sh` script performs these steps:
The `scripts/install.sh` script performs these steps:
### Step 1: Go Verification
- Checks if Go 1.21+ is installed
@ -122,6 +122,19 @@ VideoToolsClean # Clean build artifacts and cache
---
## Development Workflow
For day-to-day development:
```bash
./scripts/build.sh
./scripts/run.sh
```
Use `./scripts/install.sh` when you add new system dependencies or want to reinstall.
---
## Requirements
### Essential
@ -135,7 +148,7 @@ VideoToolsClean # Clean build artifacts and cache
```
### System
- Linux, macOS, or WSL (Windows Subsystem for Linux)
- Linux, macOS, or Windows (native)
- At least 2 GB free disk space
- Stable internet connection (for dependencies)
@ -157,7 +170,7 @@ go version
**Solution:** Check build log for specific errors:
```bash
bash install.sh
bash scripts/install.sh
# Look for error messages in the build log output
```
@ -356,4 +369,3 @@ Installation works in WSL environment. Ensure you have WSL with Linux distro ins
---
Enjoy using VideoTools! 🎬

View File

@ -88,7 +88,7 @@ The queue view now displays:
### New Files
1. **Enhanced `install.sh`** - One-command installation
1. **Enhanced `scripts/install.sh`** - One-command installation
2. **New `INSTALLATION.md`** - Comprehensive installation guide
### install.sh Features
@ -96,7 +96,7 @@ The queue view now displays:
The installer now performs all setup automatically:
```bash
bash install.sh
bash scripts/install.sh
```
This handles:
@ -113,13 +113,13 @@ This handles:
**Option 1: System-Wide (for shared computers)**
```bash
bash install.sh
bash scripts/install.sh
# Select option 1 when prompted
```
**Option 2: User-Local (default, no sudo required)**
```bash
bash install.sh
bash scripts/install.sh
# Select option 2 when prompted (or just press Enter)
```
@ -235,7 +235,7 @@ All features are built and ready:
3. Test reordering with up/down arrows
### For Testing Installation
1. Run `bash install.sh` on a clean system
1. Run `bash scripts/install.sh` on a clean system
2. Verify binary is in PATH
3. Verify aliases are available

View File

@ -14,18 +14,20 @@ Get VideoTools running in minutes!
cd VideoTools
```
2. **Run the setup script**:
- Double-click `setup-windows.bat`
- OR run in PowerShell:
```powershell
.\scripts\setup-windows.ps1 -Portable
```
2. **Install dependencies and build** (Git Bash or similar):
```bash
./scripts/install.sh
```
3. **Done!** FFmpeg will be downloaded automatically and VideoTools will be ready to run.
Or install Windows dependencies directly:
```powershell
.\scripts\install-deps-windows.ps1
```
4. **Launch VideoTools**:
- Navigate to `dist/windows/`
- Double-click `VideoTools.exe`
3. **Run VideoTools**:
```bash
./scripts/run.sh
```
### If You Need to Build
@ -70,14 +72,14 @@ If `VideoTools.exe` doesn't exist yet:
sudo pacman -S ffmpeg
```
3. **Build VideoTools**:
3. **Install dependencies and build**:
```bash
./scripts/build.sh
./scripts/install.sh
```
4. **Run**:
```bash
./VideoTools
./scripts/run.sh
```
### Cross-Compile for Windows from Linux
@ -112,16 +114,16 @@ sudo apt install gcc-mingw-w64 # Ubuntu/Debian
brew install ffmpeg
```
3. **Clone and build**:
3. **Clone and install dependencies/build**:
```bash
git clone <repository-url>
cd VideoTools
go build -o VideoTools
./scripts/install.sh
```
4. **Run**:
```bash
./VideoTools
./scripts/run.sh
```
---

944
main.go
View File

@ -2739,23 +2739,12 @@ func (s *appState) showMergeView() {
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
}
}
if len(paths) > 0 {
addFiles(paths)
}
})
listBox.Add(container.NewMax(emptyDrop))
} else {
for i, c := range s.mergeClips {
@ -13994,927 +13983,6 @@ func parseResolutionPreset(preset string, srcW, srcH int) (width, height int, pr
}
// buildUpscaleFilter builds the FFmpeg scale filter string with the selected method
func buildAuthorView(state *appState) fyne.CanvasObject {
state.stopPreview()
state.lastModule = state.active
state.active = "author"
// Initialize default values
if state.authorOutputType == "" {
state.authorOutputType = "dvd"
}
if state.authorRegion == "" {
state.authorRegion = "AUTO"
}
if state.authorAspectRatio == "" {
state.authorAspectRatio = "AUTO"
}
authorColor := moduleColor("author")
// Back button
backBtn := widget.NewButton("< BACK", func() {
state.showMainMenu()
})
backBtn.Importance = widget.LowImportance
// Queue button
queueBtn := widget.NewButton("View Queue", func() {
state.showQueue()
})
state.queueBtn = queueBtn
state.updateQueueButtonLabel()
topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn))
bottomBar := moduleFooter(authorColor, layout.NewSpacer(), state.statsBar)
// Create tabs for different authoring tasks
tabs := container.NewAppTabs(
container.NewTabItem("Video Clips", buildVideoClipsTab(state)),
container.NewTabItem("Chapters", buildChaptersTab(state)),
container.NewTabItem("Subtitles", buildSubtitlesTab(state)),
container.NewTabItem("Settings", buildAuthorSettingsTab(state)),
container.NewTabItem("Generate", buildAuthorDiscTab(state)),
)
tabs.SetTabLocation(container.TabLocationTop)
return container.NewBorder(topBar, bottomBar, nil, nil, tabs)
}
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
// Video clips list with drag-and-drop support
list := container.NewVBox()
rebuildList := func() {
list.Objects = nil
if len(state.authorClips) == 0 {
emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, clip := range state.authorClips {
idx := i
card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
rebuildList()
})
removeBtn.Importance = widget.MediumImportance
// Duration label
durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
cardContent := container.NewVBox(
durationLabel,
widget.NewSeparator(),
removeBtn,
)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add files button
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.addAuthorFiles([]string{reader.URI().Path()})
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
rebuildList()
})
clearBtn.Importance = widget.MediumImportance
// Compile button
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
// TODO: Implement compilation to DVD
dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window)
})
compileBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("Video Clips:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn, compileBtn),
)
// Initialize the list
rebuildList()
return container.NewPadded(controls)
}
// addAuthorFiles helper function
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
if len(paths) > 0 {
addFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, clip := range state.authorClips {
idx := i
clip := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
buildList()
})
removeBtn.Importance = widget.MediumImportance
// Duration label
durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
cardContent := container.NewVBox(
durationLabel,
widget.NewSeparator(),
removeBtn,
)
clip.SetContent(cardContent)
list.Add(clip)
}
}
}
addFiles := func(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), state.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
state.authorClips = append(state.authorClips, clip)
}
buildList()
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
state.authorClips = append(state.authorClips, clip)
}
buildList()
}
// Add files button
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
addFiles([]string{reader.URI().Path()})
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
buildList()
})
clearBtn.Importance = widget.MediumImportance
// Compile button
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
// TODO: Implement compilation to DVD
dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window)
})
compileBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("Video Clips:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn, compileBtn),
)
// Initialize the list
buildList()
return container.NewPadded(controls)
}
func buildChaptersTab(state *appState) fyne.CanvasObject {
var fileLabel *widget.Label
if state.authorFile != nil {
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")
}
selectBtn := widget.NewButton("Select Video", func() {
dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
path := uc.URI().Path()
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
state.authorFile = src
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(src.Path)))
}, state.window)
})
// Scene detection threshold
thresholdLabel := widget.NewLabel(fmt.Sprintf("Detection Sensitivity: %.2f", state.authorSceneThreshold))
thresholdSlider := widget.NewSlider(0.1, 0.9)
thresholdSlider.Value = state.authorSceneThreshold
thresholdSlider.Step = 0.05
thresholdSlider.OnChanged = func(v float64) {
state.authorSceneThreshold = v
thresholdLabel.SetText(fmt.Sprintf("Detection Sensitivity: %.2f", v))
}
// Detect scenes button
detectBtn := widget.NewButton("Detect Scenes", func() {
if state.authorFile == nil && len(state.authorClips) == 0 {
dialog.ShowInformation("No File", "Please select a video file first", state.window)
return
}
// TODO: Implement scene detection
dialog.ShowInformation("Scene Detection", "Scene detection will be implemented", state.window)
})
detectBtn.Importance = widget.HighImportance
// Chapter list (placeholder)
chapterList := widget.NewLabel("No chapters detected yet")
// Add manual chapter button
addChapterBtn := widget.NewButton("+ Add Chapter", func() {
// TODO: Implement manual chapter addition
dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented", state.window)
})
// Export chapters button
exportBtn := widget.NewButton("Export Chapters", func() {
// TODO: Implement chapter export
dialog.ShowInformation("Export", "Chapter export will be implemented", state.window)
})
controls := container.NewVBox(
fileLabel,
selectBtn,
widget.NewSeparator(),
widget.NewLabel("Scene Detection:"),
thresholdLabel,
thresholdSlider,
detectBtn,
widget.NewSeparator(),
widget.NewLabel("Chapters:"),
container.NewScroll(chapterList),
container.NewHBox(addChapterBtn, exportBtn),
)
return container.NewPadded(controls)
}
func buildRipTab(state *appState) fyne.CanvasObject {
placeholder := widget.NewLabel("DVD/ISO ripping will be implemented here.\n\nFeatures:\n• Mount and scan DVD/ISO\n• Select titles and tracks\n• Rip at highest quality (like FLAC from CD)\n• Preserve all audio and subtitle tracks")
placeholder.Wrapping = fyne.TextWrapWord
return container.NewCenter(placeholder)
}
// addAuthorFiles helper function
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
// addAuthorFiles helper function
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
func buildSubtitlesTab(state *appState) fyne.CanvasObject {
// Subtitle files list with drag-and-drop support
list := container.NewVBox()
var buildSubList func()
buildSubList = func() {
list.Objects = nil
if len(state.authorSubtitles) == 0 {
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.authorSubtitles = append(state.authorSubtitles, paths...)
buildSubList()
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, path := range state.authorSubtitles {
idx := i
card := widget.NewCard(filepath.Base(path), "", nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...)
buildSubList()
})
removeBtn.Importance = widget.MediumImportance
cardContent := container.NewVBox(removeBtn)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add subtitles button
addBtn := widget.NewButton("Add Subtitles", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path())
buildSubList()
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorSubtitles = []string{}
buildSubList()
})
clearBtn.Importance = widget.MediumImportance
controls := container.NewVBox(
widget.NewLabel("Subtitle Tracks:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn),
)
// Initialize
buildSubList()
return container.NewPadded(controls)
}
func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
// Output type selection
outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
}
outputType.OnChanged = func(value string) {
if value == "DVD (VIDEO_TS)" {
state.authorOutputType = "dvd"
} else {
state.authorOutputType = "iso"
}
}
// Region selection
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) {
state.authorRegion = value
})
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
// Aspect ratio selection
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) {
state.authorAspectRatio = value
})
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
// DVD title entry
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("DVD Title")
titleEntry.SetText(state.authorTitle)
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
}
// Create menu checkbox
createMenuCheck := widget.NewCheck("Create DVD Menu", state.authorCreateMenu)
createMenuCheck.OnChanged = func(checked bool) {
state.authorCreateMenu = checked
}
controls := container.NewVBox(
widget.NewLabel("Output Settings:"),
widget.NewSeparator(),
widget.NewLabel("Output Type:"),
outputType,
widget.NewLabel("Region:"),
regionSelect,
widget.NewLabel("Aspect Ratio:"),
aspectSelect,
widget.NewLabel("DVD Title:"),
titleEntry,
createMenuCheck,
)
return container.NewPadded(controls)
}
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
// Generate DVD/ISO
generateBtn := widget.NewButton("GENERATE DVD", func() {
if len(state.authorClips) == 0 && state.authorFile == nil {
dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window)
return
}
// Show compilation options
dialog.ShowFormConfirm("Generate DVD",
"Choose generation options:",
func(callback bool, options map[string]interface{}) {
if !callback {
return
}
// TODO: Implement actual DVD/ISO generation
dialog.ShowInformation("DVD Generation", "DVD/ISO generation will be implemented in next step", state.window)
},
map[string]string{
"include_subtitles": "Include Subtitles",
"include_chapters": "Include Chapters",
"preserve_quality": "Preserve Original Quality",
},
map[string]interface{}{
"include_subtitles": len(state.authorSubtitles) > 0,
"include_chapters": len(state.authorChapters) > 0,
"preserve_quality": true,
},
state.window)
})
generateBtn.Importance = widget.HighImportance
// Show summary
summary := "Ready to generate:\n\n"
if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
}
} else if state.authorFile != nil {
summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path))
}
if len(state.authorSubtitles) > 0 {
summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles))
for i, path := range state.authorSubtitles {
summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path))
}
}
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)
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
summaryLabel := widget.NewLabel(summary)
summaryLabel.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
// Video clips list with drag-and-drop support
list := container.NewVBox()
rebuildList := func() {
list.Objects = nil
if len(state.authorClips) == 0 {
emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, clip := range state.authorClips {
idx := i
card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
rebuildList()
})
removeBtn.Importance = widget.MediumImportance
// Duration label
durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
cardContent := container.NewVBox(
durationLabel,
widget.NewSeparator(),
removeBtn,
)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add files button
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.addAuthorFiles([]string{reader.URI().Path()})
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
rebuildList()
})
clearBtn.Importance = widget.MediumImportance
// Compile button
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
// TODO: Implement compilation to DVD
dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window)
})
compileBtn.Importance = widget.HighImportance
controls := container.NewVBox(
widget.NewLabel("Video Clips:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn, compileBtn),
)
// Initialize the list
rebuildList()
return container.NewPadded(controls)
}
func buildSubtitlesTab(state *appState) fyne.CanvasObject {
// Subtitle files list with drag-and-drop support
list := container.NewVBox()
rebuildSubList := func() {
list.Objects = nil
if len(state.authorSubtitles) == 0 {
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select")
emptyLabel.Alignment = fyne.TextAlignCenter
// Make empty state a drop target
emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.authorSubtitles = append(state.authorSubtitles, paths...)
rebuildSubList()
}
})
list.Add(container.NewMax(emptyDrop))
} else {
for i, path := range state.authorSubtitles {
idx := i
card := widget.NewCard(filepath.Base(path), "", nil)
// Remove button
removeBtn := widget.NewButton("Remove", func() {
state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...)
rebuildSubList()
})
removeBtn.Importance = widget.MediumImportance
cardContent := container.NewVBox(removeBtn)
card.SetContent(cardContent)
list.Add(card)
}
}
}
// Add subtitles button
addBtn := widget.NewButton("Add Subtitles", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path())
rebuildSubList()
}, state.window)
})
addBtn.Importance = widget.HighImportance
// Clear all button
clearBtn := widget.NewButton("Clear All", func() {
state.authorSubtitles = []string{}
rebuildSubList()
})
clearBtn.Importance = widget.MediumImportance
controls := container.NewVBox(
widget.NewLabel("Subtitle Tracks:"),
container.NewScroll(list),
widget.NewSeparator(),
container.NewHBox(addBtn, clearBtn),
)
// Initialize
rebuildSubList()
return container.NewPadded(controls)
}
func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
// Output type selection
outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}, func(value string) {
if value == "DVD (VIDEO_TS)" {
state.authorOutputType = "dvd"
} else {
state.authorOutputType = "iso"
}
})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
}
// Region selection
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) {
state.authorRegion = value
})
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
// Aspect ratio selection
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) {
state.authorAspectRatio = value
})
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
// DVD title entry
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("DVD Title")
titleEntry.SetText(state.authorTitle)
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
}
// Create menu checkbox
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
})
createMenuCheck.SetChecked(state.authorCreateMenu)
controls := container.NewVBox(
widget.NewLabel("Output Settings:"),
widget.NewSeparator(),
widget.NewLabel("Output Type:"),
outputType,
widget.NewLabel("Region:"),
regionSelect,
widget.NewLabel("Aspect Ratio:"),
aspectSelect,
widget.NewLabel("DVD Title:"),
titleEntry,
createMenuCheck,
)
return container.NewPadded(controls)
}
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
// Generate DVD/ISO
generateBtn := widget.NewButton("GENERATE DVD", func() {
if len(state.authorClips) == 0 && state.authorFile == nil {
dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window)
return
}
// Show compilation options
dialog.ShowInformation("DVD Generation",
"DVD/ISO generation will be implemented in next step.\n\n"+
"Features planned:\n"+
"• Create VIDEO_TS folder structure\n"+
"• Generate burn-ready ISO\n"+
"• Include subtitle tracks\n"+
"• Include alternate audio tracks\n"+
"• Support for alternate camera angles", state.window)
})
generateBtn.Importance = widget.HighImportance
// Show summary
summary := "Ready to generate:\n\n"
if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
}
} else if state.authorFile != nil {
summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path))
}
if len(state.authorSubtitles) > 0 {
summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles))
for i, path := range state.authorSubtitles {
summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path))
}
}
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)
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
summaryLabel := widget.NewLabel(summary)
summaryLabel.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}
// addAuthorFiles helper function
func (s *appState) addAuthorFiles(paths []string) {
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
}
s.authorClips = append(s.authorClips, clip)
}
}
func buildUpscaleFilter(targetWidth, targetHeight int, method string, preserveAspect bool) string {
// Ensure even dimensions for encoders
makeEven := func(v int) int {

View File

@ -2,6 +2,18 @@
This directory contains scripts for building and managing VideoTools on different platforms.
## Recommended Workflow
For development on any platform:
```bash
./scripts/install.sh
./scripts/build.sh
./scripts/run.sh
```
Use `./scripts/install.sh` whenever you add new dependencies or need to reinstall.
## Linux
### Install Dependencies
@ -73,6 +85,7 @@ Run in PowerShell as Administrator:
- MinGW-w64 (GCC compiler)
- ffmpeg
- Git (optional, for development)
- DVD authoring tools (via DVDStyler portable: dvdauthor + mkisofs)
**Package managers supported:**
- Chocolatey (default, requires admin)

View File

@ -9,11 +9,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_VERSION="$(grep -m1 'appVersion' "$PROJECT_ROOT/main.go" | sed -E 's/.*\"([^\"]+)\".*/\1/')"
[ -z "$APP_VERSION" ] && APP_VERSION="(version unknown)"
echo "════════════════════════════════════════════════════════════════"
echo " VideoTools Universal Build Script"
echo "════════════════════════════════════════════════════════════════"
echo ""
# Detect platform
PLATFORM="$(uname -s)"
case "$PLATFORM" in
@ -22,6 +17,11 @@ case "$PLATFORM" in
CYGWIN*|MINGW*|MSYS*) OS="Windows" ;;
*) echo "❌ Unknown platform: $PLATFORM"; exit 1 ;;
esac
echo "════════════════════════════════════════════════════════════════"
echo " VideoTools ${OS} Build"
echo "════════════════════════════════════════════════════════════════"
echo ""
echo "🔍 Detected platform: $OS"
echo ""

View File

@ -5,7 +5,7 @@
set -e
echo "════════════════════════════════════════════════════════════════"
echo " VideoTools Dependency Installer (Linux)"
echo " VideoTools Linux Installation"
echo "════════════════════════════════════════════════════════════════"
echo ""

View File

@ -4,60 +4,22 @@ chcp 65001 >nul
title VideoTools Windows Dependency Installer
echo ========================================================
echo VideoTools Windows Dependency Installer (.bat)
echo Installs Go, MinGW (GCC), Git, and FFmpeg
echo VideoTools Windows Installation
echo Delegating to PowerShell for full dependency setup
echo ========================================================
echo.
REM Prefer Chocolatey if available; otherwise fall back to winget.
where choco >nul 2>&1
if %errorlevel%==0 (
echo Using Chocolatey...
call :install_choco
goto :verify
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-deps-windows.ps1"
set EXIT_CODE=%errorlevel%
if not %EXIT_CODE%==0 (
echo.
echo Dependency installer failed with exit code %EXIT_CODE%.
pause
exit /b %EXIT_CODE%
)
where winget >nul 2>&1
if %errorlevel%==0 (
echo Chocolatey not found; using winget...
call :install_winget
goto :verify
)
echo Neither Chocolatey nor winget found.
echo Please install Chocolatey (recommended): https://chocolatey.org/install
echo Then re-run this script.
pause
exit /b 1
:install_choco
echo.
echo Installing dependencies via Chocolatey...
choco install -y golang mingw git ffmpeg
goto :eof
:install_winget
echo.
echo Installing dependencies via winget...
REM Winget package IDs can vary; these are common defaults.
winget install -e --id GoLang.Go
winget install -e --id Git.Git
winget install -e --id GnuWin32.Mingw
winget install -e --id Gyan.FFmpeg
goto :eof
:verify
echo.
echo ========================================================
echo Verifying installs
echo ========================================================
where go >nul 2>&1 && go version
where gcc >nul 2>&1 && gcc --version | findstr /R /C:"gcc"
where git >nul 2>&1 && git --version
where ffmpeg >nul 2>&1 && ffmpeg -version | head -n 1
echo.
echo Done. If any tool is missing, ensure its bin folder is in PATH
echo (restart terminal after installation).
echo Done. Restart your terminal to refresh PATH.
pause
exit /b 0

View File

@ -7,7 +7,7 @@ param(
)
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " VideoTools Dependency Installer (Windows)" -ForegroundColor Cyan
Write-Host " VideoTools Windows Installation" -ForegroundColor Cyan
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
@ -32,6 +32,57 @@ function Test-Command {
return $?
}
# Ensure DVD authoring tools exist on Windows by downloading DVDStyler portable
function Ensure-DVDStylerTools {
$toolsRoot = Join-Path $PSScriptRoot "tools"
$dvdstylerDir = Join-Path $toolsRoot "dvdstyler"
$dvdstylerBin = Join-Path $dvdstylerDir "bin"
$dvdstylerUrl = "https://sourceforge.net/projects/dvdstyler/files/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip/download"
$dvdstylerZip = Join-Path $env:TEMP "dvdstyler-win64.zip"
$needsDVDTools = (-not (Test-Command dvdauthor)) -or (-not (Test-Command mkisofs))
if (-not $needsDVDTools) {
return
}
Write-Host "Installing DVD authoring tools (DVDStyler portable)..." -ForegroundColor Yellow
if (-not (Test-Path $toolsRoot)) {
New-Item -ItemType Directory -Force -Path $toolsRoot | Out-Null
}
if (Test-Path $dvdstylerDir) {
Remove-Item -Recurse -Force $dvdstylerDir
}
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-WebRequest -Uri $dvdstylerUrl -OutFile $dvdstylerZip
$extractRoot = Join-Path $env:TEMP ("dvdstyler-extract-" + [System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
Expand-Archive -Path $dvdstylerZip -DestinationPath $extractRoot -Force
$entries = Get-ChildItem -Path $extractRoot
if ($entries.Count -eq 1 -and $entries[0].PSIsContainer) {
Copy-Item -Path (Join-Path $entries[0].FullName "*") -Destination $dvdstylerDir -Recurse -Force
} else {
Copy-Item -Path (Join-Path $extractRoot "*") -Destination $dvdstylerDir -Recurse -Force
}
Remove-Item -Force $dvdstylerZip
Remove-Item -Recurse -Force $extractRoot
if (Test-Path $dvdstylerBin) {
$env:Path = "$dvdstylerBin;$env:Path"
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notmatch [Regex]::Escape($dvdstylerBin)) {
[Environment]::SetEnvironmentVariable("Path", "$dvdstylerBin;$userPath", "User")
}
Write-Host "✓ DVD authoring tools installed to $dvdstylerDir" -ForegroundColor Green
} else {
Write-Host "❌ DVDStyler tools missing after install" -ForegroundColor Red
exit 1
}
}
# Function to install via Chocolatey
function Install-ViaChocolatey {
Write-Host "📦 Using Chocolatey package manager..." -ForegroundColor Green
@ -191,6 +242,8 @@ if ($UseScoop) {
}
}
Ensure-DVDStylerTools
Write-Host ""
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host "✅ DEPENDENCIES INSTALLED" -ForegroundColor Green
@ -229,6 +282,18 @@ if (Test-Command ffmpeg) {
}
}
if (Test-Command dvdauthor) {
Write-Host "✓ dvdauthor: found" -ForegroundColor Green
} else {
Write-Host "⚠️ dvdauthor not found in PATH (restart terminal)" -ForegroundColor Yellow
}
if (Test-Command mkisofs) {
Write-Host "✓ mkisofs: found" -ForegroundColor Green
} else {
Write-Host "⚠️ mkisofs not found in PATH (restart terminal)" -ForegroundColor Yellow
}
if (Test-Command git) {
$gitVersion = git --version
Write-Host "✓ Git: $gitVersion" -ForegroundColor Green

View File

@ -27,17 +27,43 @@ spinner() {
# Configuration
BINARY_NAME="VideoTools"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEFAULT_INSTALL_PATH="/usr/local/bin"
USER_INSTALL_PATH="$HOME/.local/bin"
# Platform detection
UNAME_S="$(uname -s)"
IS_WINDOWS=false
IS_DARWIN=false
IS_LINUX=false
case "$UNAME_S" in
MINGW*|MSYS*|CYGWIN*)
IS_WINDOWS=true
;;
Darwin*)
IS_DARWIN=true
;;
Linux*)
IS_LINUX=true
;;
esac
INSTALL_TITLE="VideoTools Installation"
if [ "$IS_WINDOWS" = true ]; then
INSTALL_TITLE="VideoTools Windows Installation"
elif [ "$IS_DARWIN" = true ]; then
INSTALL_TITLE="VideoTools macOS Installation"
elif [ "$IS_LINUX" = true ]; then
INSTALL_TITLE="VideoTools Linux Installation"
fi
echo "════════════════════════════════════════════════════════════════"
echo " VideoTools Professional Installation"
echo " $INSTALL_TITLE"
echo "════════════════════════════════════════════════════════════════"
echo ""
# Step 1: Check if Go is installed
echo -e "${CYAN}[1/5]${NC} Checking Go installation..."
echo -e "${CYAN}[1/6]${NC} Checking Go installation..."
if ! command -v go &> /dev/null; then
echo -e "${RED}✗ Error: Go is not installed or not in PATH${NC}"
echo "Please install Go 1.21+ from https://go.dev/dl/"
@ -47,9 +73,77 @@ fi
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
echo -e "${GREEN}${NC} Found Go version: $GO_VERSION"
# Step 2: Build the binary
# Step 2: Check authoring dependencies
echo ""
echo -e "${CYAN}[2/5]${NC} Building VideoTools..."
echo -e "${CYAN}[2/6]${NC} Checking authoring dependencies..."
if [ "$IS_WINDOWS" = true ]; then
echo "Detected Windows environment."
if command -v powershell.exe &> /dev/null; then
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PROJECT_ROOT/scripts/install-deps-windows.ps1"
echo -e "${GREEN}${NC} Windows dependency installer completed"
else
echo -e "${RED}✗ powershell.exe not found.${NC}"
echo "Please run: $PROJECT_ROOT\\scripts\\install-deps-windows.ps1"
exit 1
fi
else
missing_deps=()
if ! command -v ffmpeg &> /dev/null; then
missing_deps+=("ffmpeg")
fi
if ! command -v dvdauthor &> /dev/null; then
missing_deps+=("dvdauthor")
fi
if ! command -v mkisofs &> /dev/null && ! command -v genisoimage &> /dev/null && ! command -v xorriso &> /dev/null; then
missing_deps+=("iso-tool")
fi
install_deps=false
if [ ${#missing_deps[@]} -gt 0 ]; then
echo -e "${YELLOW}WARNING${NC} Missing dependencies: ${missing_deps[*]}"
read -p "Install missing dependencies now? [y/N]: " install_choice
if [[ "$install_choice" =~ ^[Yy]$ ]]; then
install_deps=true
fi
else
echo -e "${GREEN}${NC} All authoring dependencies found"
fi
if [ "$install_deps" = true ]; then
if command -v apt-get &> /dev/null; then
sudo apt-get update
sudo apt-get install -y ffmpeg dvdauthor genisoimage
elif command -v dnf &> /dev/null; then
sudo dnf install -y ffmpeg dvdauthor genisoimage
elif command -v pacman &> /dev/null; then
sudo pacman -Sy --noconfirm ffmpeg dvdauthor cdrtools
elif command -v zypper &> /dev/null; then
sudo zypper install -y ffmpeg dvdauthor genisoimage
elif command -v brew &> /dev/null; then
brew install ffmpeg dvdauthor xorriso
else
echo -e "${RED}✗ No supported package manager found.${NC}"
echo "Please install: ffmpeg, dvdauthor, and mkisofs/genisoimage/xorriso"
exit 1
fi
fi
if ! command -v ffmpeg &> /dev/null || ! command -v dvdauthor &> /dev/null; then
echo -e "${RED}✗ Missing required dependencies after install attempt.${NC}"
echo "Please install: ffmpeg and dvdauthor"
exit 1
fi
if ! command -v mkisofs &> /dev/null && ! command -v genisoimage &> /dev/null && ! command -v xorriso &> /dev/null; then
echo -e "${RED}✗ Missing ISO creation tool after install attempt.${NC}"
echo "Please install: mkisofs (cdrtools), genisoimage, or xorriso"
exit 1
fi
fi
# Step 3: Build the binary
echo ""
echo -e "${CYAN}[3/6]${NC} Building VideoTools..."
cd "$PROJECT_ROOT"
CGO_ENABLED=1 go build -o "$BINARY_NAME" . > /tmp/videotools-build.log 2>&1 &
BUILD_PID=$!
@ -67,9 +161,9 @@ else
fi
rm -f /tmp/videotools-build.log
# Step 3: Determine installation path
# Step 4: Determine installation path
echo ""
echo -e "${CYAN}[3/5]${NC} Installation path selection"
echo -e "${CYAN}[4/6]${NC} Installation path selection"
echo ""
echo "Where would you like to install $BINARY_NAME?"
echo " 1) System-wide (/usr/local/bin) - requires sudo, available to all users"
@ -95,15 +189,12 @@ case $choice in
;;
esac
# Step 4: Install the binary
# Step 5: Install the binary
echo ""
echo -e "${CYAN}[4/5]${NC} Installing binary to $INSTALL_PATH..."
echo -e "${CYAN}[5/6]${NC} Installing binary to $INSTALL_PATH..."
if [ "$NEEDS_SUDO" = true ]; then
sudo install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1 &
INSTALL_PID=$!
spinner $INSTALL_PID "Installing $BINARY_NAME"
if wait $INSTALL_PID; then
echo "Installing $BINARY_NAME (sudo required)..."
if sudo install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1; then
echo -e "${GREEN}${NC} Installation successful"
else
echo -e "${RED}✗ Installation failed${NC}"
@ -126,9 +217,9 @@ fi
rm -f "$BINARY_NAME"
# Step 5: Setup shell aliases and environment
# Step 6: Setup shell aliases and environment
echo ""
echo -e "${CYAN}[5/5]${NC} Setting up shell environment..."
echo -e "${CYAN}[6/6]${NC} Setting up shell environment..."
# Detect shell
if [ -n "$ZSH_VERSION" ]; then
@ -167,21 +258,21 @@ fi
echo ""
echo "════════════════════════════════════════════════════════════════"
echo -e "${GREEN}Installation Complete!${NC}"
echo "Installation Complete!"
echo "════════════════════════════════════════════════════════════════"
echo ""
echo "Next steps:"
echo ""
echo "1. ${CYAN}Reload your shell configuration:${NC}"
echo "1. Reload your shell configuration:"
echo " source $SHELL_RC"
echo ""
echo "2. ${CYAN}Run VideoTools:${NC}"
echo "2. Run VideoTools:"
echo " VideoTools"
echo ""
echo "3. ${CYAN}Available commands:${NC}"
echo " VideoTools - Run the application"
echo " VideoToolsRebuild - Force rebuild from source"
echo " VideoToolsClean - Clean build artifacts and cache"
echo "3. Available commands:"
echo " - VideoTools - Run the application"
echo " - VideoToolsRebuild - Force rebuild from source"
echo " - VideoToolsClean - Clean build artifacts and cache"
echo ""
echo "For more information, see BUILD_AND_RUN.md and DVD_USER_GUIDE.md"
echo ""

View File

@ -5,8 +5,17 @@
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_OUTPUT="$PROJECT_ROOT/VideoTools"
# Detect platform
PLATFORM="$(uname -s)"
case "$PLATFORM" in
Linux*) OS="Linux" ;;
Darwin*) OS="macOS" ;;
CYGWIN*|MINGW*|MSYS*) OS="Windows" ;;
*) echo "❌ Unknown platform: $PLATFORM"; exit 1 ;;
esac
echo "════════════════════════════════════════════════════════════════"
echo " VideoTools - Run Script"
echo " VideoTools ${OS} Run"
echo "════════════════════════════════════════════════════════════════"
echo ""