diff --git a/README.md b/README.md index d73d386..15fb535 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/author_dvd_functions.go b/author_dvd_functions.go new file mode 100644 index 0000000..ba35a82 --- /dev/null +++ b/author_dvd_functions.go @@ -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) + } +} diff --git a/author_module.go b/author_module.go new file mode 100644 index 0000000..1fbc8b8 --- /dev/null +++ b/author_module.go @@ -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("\n") + b.WriteString(" \n") + b.WriteString(" \n") + b.WriteString(" \n") + b.WriteString(fmt.Sprintf(" \n") + b.WriteString(" \n") + b.WriteString("\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, "\"", """) + } + escaped := b.String() + return strings.ReplaceAll(escaped, "\"", """) +} + +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() +} diff --git a/author_module_temp.go b/author_module_temp.go deleted file mode 100644 index 2687870..0000000 --- a/author_module_temp.go +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 67a1c1f..708866e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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`) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d28034e..fb9e86b 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -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! šŸŽ¬ - diff --git a/docs/LATEST_UPDATES.md b/docs/LATEST_UPDATES.md index e37b5f7..f1800e1 100644 --- a/docs/LATEST_UPDATES.md +++ b/docs/LATEST_UPDATES.md @@ -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 diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 69c3ff8..fd38360 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -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 cd VideoTools - go build -o VideoTools + ./scripts/install.sh ``` 4. **Run**: ```bash - ./VideoTools + ./scripts/run.sh ``` --- diff --git a/main.go b/main.go index 3301265..dadd839 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/scripts/README.md b/scripts/README.md index 4ed480e..4d4d2f6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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) diff --git a/scripts/build.sh b/scripts/build.sh index 6494d9b..c170643 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 "" diff --git a/scripts/install-deps-linux.sh b/scripts/install-deps-linux.sh index 0566355..d5bf6ec 100755 --- a/scripts/install-deps-linux.sh +++ b/scripts/install-deps-linux.sh @@ -5,7 +5,7 @@ set -e echo "════════════════════════════════════════════════════════════════" -echo " VideoTools Dependency Installer (Linux)" +echo " VideoTools Linux Installation" echo "════════════════════════════════════════════════════════════════" echo "" diff --git a/scripts/install-deps-windows.bat b/scripts/install-deps-windows.bat index df37e92..2985c25 100644 --- a/scripts/install-deps-windows.bat +++ b/scripts/install-deps-windows.bat @@ -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 diff --git a/scripts/install-deps-windows.ps1 b/scripts/install-deps-windows.ps1 index dfa6d68..a8713f7 100644 --- a/scripts/install-deps-windows.ps1 +++ b/scripts/install-deps-windows.ps1 @@ -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 diff --git a/install.sh b/scripts/install.sh similarity index 52% rename from install.sh rename to scripts/install.sh index 667be12..6fbaee5 100755 --- a/install.sh +++ b/scripts/install.sh @@ -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 "" diff --git a/scripts/run.sh b/scripts/run.sh index 023e1e1..b96bcc5 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -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 ""