Compare commits

...

3 Commits

Author SHA1 Message Date
49e01f5817 Fix DVD authoring SCR errors and queue animation persistence
DVD Authoring Fix:
- Add remultiplex step after MPEG encoding for DVD compliance
- Use ffmpeg -fflags +genpts -c copy -f dvd to fix timestamps
- Resolves "ERR: SCR moves backwards" error from dvdauthor
- FFmpeg direct encoding doesn't always create DVD-compliant streams
- Remux regenerates presentation timestamps correctly

Queue Animation Fix:
- Stop stripe animation on completed jobs
- Bug: Refresh() was always incrementing offset regardless of state
- Now only increments offset when animStop != nil (animation running)
- Completed/failed/cancelled jobs no longer show animated stripes

Testing:
- DVD authoring should now succeed on AVI files
- Completed queue jobs should show static progress bar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:20:14 -05:00
e919339e3d Stabilize queue back navigation 2025-12-24 16:22:24 -05:00
7226da0970 Add persistent configs for author/subtitles/merge/rip 2025-12-24 15:39:22 -05:00
7 changed files with 637 additions and 6 deletions

View File

@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"os"
@ -28,11 +29,106 @@ import (
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
type authorConfig struct {
OutputType string `json:"outputType"`
Region string `json:"region"`
AspectRatio string `json:"aspectRatio"`
DiscSize string `json:"discSize"`
Title string `json:"title"`
CreateMenu bool `json:"createMenu"`
TreatAsChapters bool `json:"treatAsChapters"`
SceneThreshold float64 `json:"sceneThreshold"`
}
func defaultAuthorConfig() authorConfig {
return authorConfig{
OutputType: "dvd",
Region: "AUTO",
AspectRatio: "AUTO",
DiscSize: "DVD5",
Title: "",
CreateMenu: false,
TreatAsChapters: false,
SceneThreshold: 0.3,
}
}
func loadPersistedAuthorConfig() (authorConfig, error) {
var cfg authorConfig
path := moduleConfigPath("author")
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
if cfg.OutputType == "" {
cfg.OutputType = "dvd"
}
if cfg.Region == "" {
cfg.Region = "AUTO"
}
if cfg.AspectRatio == "" {
cfg.AspectRatio = "AUTO"
}
if cfg.DiscSize == "" {
cfg.DiscSize = "DVD5"
}
if cfg.SceneThreshold <= 0 {
cfg.SceneThreshold = 0.3
}
return cfg, nil
}
func savePersistedAuthorConfig(cfg authorConfig) error {
path := moduleConfigPath("author")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (s *appState) applyAuthorConfig(cfg authorConfig) {
s.authorOutputType = cfg.OutputType
s.authorRegion = cfg.Region
s.authorAspectRatio = cfg.AspectRatio
s.authorDiscSize = cfg.DiscSize
s.authorTitle = cfg.Title
s.authorCreateMenu = cfg.CreateMenu
s.authorTreatAsChapters = cfg.TreatAsChapters
s.authorSceneThreshold = cfg.SceneThreshold
}
func (s *appState) persistAuthorConfig() {
cfg := authorConfig{
OutputType: s.authorOutputType,
Region: s.authorRegion,
AspectRatio: s.authorAspectRatio,
DiscSize: s.authorDiscSize,
Title: s.authorTitle,
CreateMenu: s.authorCreateMenu,
TreatAsChapters: s.authorTreatAsChapters,
SceneThreshold: s.authorSceneThreshold,
}
if err := savePersistedAuthorConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist author config: %v", err)
}
}
func buildAuthorView(state *appState) fyne.CanvasObject {
state.stopPreview()
state.lastModule = state.active
state.active = "author"
if cfg, err := loadPersistedAuthorConfig(); err == nil {
state.applyAuthorConfig(cfg)
}
if state.authorOutputType == "" {
state.authorOutputType = "dvd"
}
@ -178,6 +274,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.authorChapters = nil
}
state.updateAuthorSummary()
state.persistAuthorConfig()
if state.authorChaptersRefresh != nil {
state.authorChaptersRefresh()
}
@ -285,6 +382,7 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
thresholdSlider.OnChanged = func(v float64) {
state.authorSceneThreshold = v
thresholdLabel.SetText(fmt.Sprintf("Detection Sensitivity: %.2f", v))
state.persistAuthorConfig()
}
detectBtn := widget.NewButton("Detect Scenes", func() {
@ -472,6 +570,7 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
state.authorOutputType = "iso"
}
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
@ -482,6 +581,7 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) {
state.authorRegion = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
@ -492,6 +592,7 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) {
state.authorAspectRatio = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
@ -505,17 +606,20 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
state.updateAuthorSummary()
state.persistAuthorConfig()
}
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
state.updateAuthorSummary()
state.persistAuthorConfig()
})
createMenuCheck.SetChecked(state.authorCreateMenu)
discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) {
state.authorDiscSize = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorDiscSize == "" {
discSizeSelect.SetSelected("DVD5")
@ -523,6 +627,72 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
discSizeSelect.SetSelected(state.authorDiscSize)
}
applyControls := func() {
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
} else {
outputType.SetSelected("DVD (VIDEO_TS)")
}
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
if state.authorDiscSize == "" {
discSizeSelect.SetSelected("DVD5")
} else {
discSizeSelect.SetSelected(state.authorDiscSize)
}
titleEntry.SetText(state.authorTitle)
createMenuCheck.SetChecked(state.authorCreateMenu)
}
loadCfgBtn := widget.NewButton("Load Config", func() {
cfg, err := loadPersistedAuthorConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
} else {
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
}
return
}
state.applyAuthorConfig(cfg)
applyControls()
state.updateAuthorSummary()
})
saveCfgBtn := widget.NewButton("Save Config", func() {
cfg := authorConfig{
OutputType: state.authorOutputType,
Region: state.authorRegion,
AspectRatio: state.authorAspectRatio,
DiscSize: state.authorDiscSize,
Title: state.authorTitle,
CreateMenu: state.authorCreateMenu,
TreatAsChapters: state.authorTreatAsChapters,
SceneThreshold: state.authorSceneThreshold,
}
if err := savePersistedAuthorConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
return
}
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("author")), state.window)
})
resetBtn := widget.NewButton("Reset", func() {
cfg := defaultAuthorConfig()
state.applyAuthorConfig(cfg)
applyControls()
state.updateAuthorSummary()
state.persistAuthorConfig()
})
info := widget.NewLabel("Requires: ffmpeg, dvdauthor, and mkisofs/genisoimage (for ISO).")
info.Wrapping = fyne.TextWrapWord
@ -542,6 +712,8 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
createMenuCheck,
widget.NewSeparator(),
info,
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
)
return container.NewPadded(controls)
@ -1443,7 +1615,28 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, logFn); err != nil {
return err
}
mpgPaths = append(mpgPaths, outPath)
// Remultiplex the MPEG to fix timestamps for DVD compliance
// This resolves "SCR moves backwards" errors from dvdauthor
remuxPath := filepath.Join(workDir, fmt.Sprintf("title_%02d_remux.mpg", i+1))
remuxArgs := []string{
"-fflags", "+genpts",
"-i", outPath,
"-c", "copy",
"-f", "dvd",
"-y",
remuxPath,
}
if logFn != nil {
logFn(fmt.Sprintf(">> ffmpeg %s (remuxing for DVD compliance)", strings.Join(remuxArgs, " ")))
}
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, remuxArgs, logFn); err != nil {
return fmt.Errorf("remux failed: %w", err)
}
// Remove original encode, use remuxed version
os.Remove(outPath)
mpgPaths = append(mpgPaths, remuxPath)
advance("")
}

20
config_helpers.go Normal file
View File

@ -0,0 +1,20 @@
package main
import (
"os"
"path/filepath"
)
func moduleConfigPath(name string) string {
configDir, err := os.UserConfigDir()
if err != nil || configDir == "" {
home := os.Getenv("HOME")
if home != "" {
configDir = filepath.Join(home, ".config")
}
}
if configDir == "" {
return name + ".json"
}
return filepath.Join(configDir, "VideoTools", name+".json")
}

View File

@ -171,8 +171,14 @@ func (r *stripedProgressRenderer) MinSize() fyne.Size {
}
func (r *stripedProgressRenderer) Refresh() {
// small drift to animate stripes
r.bar.offset += 2
// Only animate stripes when animation is active
r.bar.animMu.Lock()
shouldAnimate := r.bar.animStop != nil
r.bar.animMu.Unlock()
if shouldAnimate {
r.bar.offset += 2
}
r.Layout(r.bg.Size())
canvas.Refresh(r.bg)
canvas.Refresh(r.stripes)

99
main.go
View File

@ -816,6 +816,8 @@ type appState struct {
window fyne.Window
active string
lastModule string
queueBackTarget string
queueLastRefresh time.Time
navigationHistory []string // Track module navigation history for back/forward buttons
navigationHistoryPosition int // Current position in navigation history
navigationHistorySuppress bool // Temporarily suppress history tracking during navigation
@ -1593,6 +1595,7 @@ func (s *appState) showMainMenu() {
s.stopPlayer()
s.stopQueueAutoRefresh()
s.active = ""
s.queueBackTarget = ""
// Track navigation history
s.pushNavigationHistory("mainmenu")
@ -1697,7 +1700,10 @@ func (s *appState) showMainMenu() {
func (s *appState) showQueue() {
s.stopPreview()
s.stopPlayer()
s.lastModule = s.active
if s.active != "queue" {
s.lastModule = s.active
s.queueBackTarget = s.active
}
s.active = "queue"
s.refreshQueueView()
s.startQueueAutoRefresh()
@ -1705,6 +1711,14 @@ func (s *appState) showQueue() {
// refreshQueueView rebuilds the queue UI while preserving scroll position and inline active conversion.
func (s *appState) refreshQueueView() {
if s.active == "queue" {
now := time.Now()
if !s.queueLastRefresh.IsZero() && now.Sub(s.queueLastRefresh) < 500*time.Millisecond {
return
}
s.queueLastRefresh = now
}
// Preserve current scroll offset if we already have a view
if s.queueScroll != nil {
s.queueOffset = s.queueScroll.Offset
@ -1736,8 +1750,12 @@ func (s *appState) refreshQueueView() {
view, scroll := ui.BuildQueueView(
jobs,
func() { // onBack
if s.lastModule != "" && s.lastModule != "queue" && s.lastModule != "menu" {
s.showModule(s.lastModule)
target := s.queueBackTarget
if target == "" {
target = s.lastModule
}
if target != "" && target != "queue" && target != "menu" {
s.showModule(target)
} else {
s.showMainMenu()
}
@ -2957,6 +2975,12 @@ func (s *appState) showMergeView() {
mergeColor := moduleColor("merge")
if cfg, err := loadPersistedMergeConfig(); err == nil {
s.applyMergeConfig(cfg)
} else if !errors.Is(err, os.ErrNotExist) {
logging.Debug(logging.CatSystem, "failed to load persisted merge config: %v", err)
}
if s.mergeFormat == "" {
s.mergeFormat = "mkv-copy"
}
@ -3130,11 +3154,13 @@ func (s *appState) showMergeView() {
keepAllCheck := widget.NewCheck("Keep all audio/subtitle tracks", func(v bool) {
s.mergeKeepAll = v
s.persistMergeConfig()
})
keepAllCheck.SetChecked(s.mergeKeepAll)
chapterCheck := widget.NewCheck("Create chapters from each clip", func(v bool) {
s.mergeChapters = v
s.persistMergeConfig()
})
chapterCheck.SetChecked(s.mergeChapters)
@ -3169,11 +3195,13 @@ func (s *appState) showMergeView() {
// DVD-specific options
dvdRegionSelect := widget.NewSelect([]string{"NTSC", "PAL"}, func(val string) {
s.mergeDVDRegion = val
s.persistMergeConfig()
})
dvdRegionSelect.SetSelected(s.mergeDVDRegion)
dvdAspectSelect := widget.NewSelect([]string{"16:9", "4:3"}, func(val string) {
s.mergeDVDAspect = val
s.persistMergeConfig()
})
dvdAspectSelect.SetSelected(s.mergeDVDAspect)
@ -3216,6 +3244,7 @@ func (s *appState) showMergeView() {
// Update extension of existing path
updateOutputExt()
}
s.persistMergeConfig()
})
for label, val := range formatMap {
if val == s.mergeFormat {
@ -3234,11 +3263,13 @@ func (s *appState) showMergeView() {
// Frame Rate controls
frameRateSelect := widget.NewSelect([]string{"Source", "23.976", "24", "25", "29.97", "30", "50", "59.94", "60"}, func(val string) {
s.mergeFrameRate = val
s.persistMergeConfig()
})
frameRateSelect.SetSelected(s.mergeFrameRate)
motionInterpCheck := widget.NewCheck("Use Motion Interpolation (slower, smoother)", func(checked bool) {
s.mergeMotionInterpolation = checked
s.persistMergeConfig()
})
motionInterpCheck.SetChecked(s.mergeMotionInterpolation)
@ -3284,6 +3315,66 @@ func (s *appState) showMergeView() {
runNowBtn.Disable()
}
applyMergeControls := func() {
for label, val := range formatMap {
if val == s.mergeFormat {
formatSelect.SetSelected(label)
break
}
}
keepAllCheck.SetChecked(s.mergeKeepAll)
chapterCheck.SetChecked(s.mergeChapters)
dvdRegionSelect.SetSelected(s.mergeDVDRegion)
dvdAspectSelect.SetSelected(s.mergeDVDAspect)
frameRateSelect.SetSelected(s.mergeFrameRate)
motionInterpCheck.SetChecked(s.mergeMotionInterpolation)
if s.mergeFormat == "dvd" {
dvdOptionsContainer.Show()
} else {
dvdOptionsContainer.Hide()
}
updateOutputExt()
}
loadCfgBtn := widget.NewButton("Load Config", func() {
cfg, err := loadPersistedMergeConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", s.window)
} else {
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), s.window)
}
return
}
s.applyMergeConfig(cfg)
applyMergeControls()
})
saveCfgBtn := widget.NewButton("Save Config", func() {
cfg := mergeConfig{
Format: s.mergeFormat,
KeepAllStreams: s.mergeKeepAll,
Chapters: s.mergeChapters,
CodecMode: s.mergeCodecMode,
DVDRegion: s.mergeDVDRegion,
DVDAspect: s.mergeDVDAspect,
FrameRate: s.mergeFrameRate,
MotionInterpolation: s.mergeMotionInterpolation,
}
if err := savePersistedMergeConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), s.window)
return
}
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("merge")), s.window)
})
resetBtn := widget.NewButton("Reset", func() {
cfg := defaultMergeConfig()
s.applyMergeConfig(cfg)
applyMergeControls()
s.persistMergeConfig()
})
listScroll := container.NewVScroll(listBox)
// Use border layout so the list expands to fill available vertical space
@ -3324,6 +3415,8 @@ func (s *appState) showMergeView() {
widget.NewLabel("Output Path"),
container.NewBorder(nil, nil, nil, browseOut, outputEntry),
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
widget.NewSeparator(),
container.NewHBox(addQueueBtn, runNowBtn),
)

97
merge_config.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
)
type mergeConfig struct {
Format string `json:"format"`
KeepAllStreams bool `json:"keepAllStreams"`
Chapters bool `json:"chapters"`
CodecMode string `json:"codecMode"`
DVDRegion string `json:"dvdRegion"`
DVDAspect string `json:"dvdAspect"`
FrameRate string `json:"frameRate"`
MotionInterpolation bool `json:"motionInterpolation"`
}
func defaultMergeConfig() mergeConfig {
return mergeConfig{
Format: "mkv-copy",
KeepAllStreams: false,
Chapters: true,
CodecMode: "",
DVDRegion: "NTSC",
DVDAspect: "16:9",
FrameRate: "Source",
MotionInterpolation: false,
}
}
func loadPersistedMergeConfig() (mergeConfig, error) {
var cfg mergeConfig
path := moduleConfigPath("merge")
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
if cfg.Format == "" {
cfg.Format = "mkv-copy"
}
if cfg.DVDRegion == "" {
cfg.DVDRegion = "NTSC"
}
if cfg.DVDAspect == "" {
cfg.DVDAspect = "16:9"
}
if cfg.FrameRate == "" {
cfg.FrameRate = "Source"
}
return cfg, nil
}
func savePersistedMergeConfig(cfg mergeConfig) error {
path := moduleConfigPath("merge")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (s *appState) applyMergeConfig(cfg mergeConfig) {
s.mergeFormat = cfg.Format
s.mergeKeepAll = cfg.KeepAllStreams
s.mergeChapters = cfg.Chapters
s.mergeCodecMode = cfg.CodecMode
s.mergeDVDRegion = cfg.DVDRegion
s.mergeDVDAspect = cfg.DVDAspect
s.mergeFrameRate = cfg.FrameRate
s.mergeMotionInterpolation = cfg.MotionInterpolation
}
func (s *appState) persistMergeConfig() {
cfg := mergeConfig{
Format: s.mergeFormat,
KeepAllStreams: s.mergeKeepAll,
Chapters: s.mergeChapters,
CodecMode: s.mergeCodecMode,
DVDRegion: s.mergeDVDRegion,
DVDAspect: s.mergeDVDAspect,
FrameRate: s.mergeFrameRate,
MotionInterpolation: s.mergeMotionInterpolation,
}
if err := savePersistedMergeConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist merge config: %v", err)
}
}

View File

@ -3,6 +3,8 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@ -28,11 +30,68 @@ const (
ripFormatH264MP4 = "H.264 MP4 (CRF 18)"
)
type ripConfig struct {
Format string `json:"format"`
}
func defaultRipConfig() ripConfig {
return ripConfig{
Format: ripFormatLosslessMKV,
}
}
func loadPersistedRipConfig() (ripConfig, error) {
var cfg ripConfig
path := moduleConfigPath("rip")
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
if cfg.Format == "" {
cfg.Format = ripFormatLosslessMKV
}
return cfg, nil
}
func savePersistedRipConfig(cfg ripConfig) error {
path := moduleConfigPath("rip")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (s *appState) applyRipConfig(cfg ripConfig) {
s.ripFormat = cfg.Format
}
func (s *appState) persistRipConfig() {
cfg := ripConfig{
Format: s.ripFormat,
}
if err := savePersistedRipConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist rip config: %v", err)
}
}
func (s *appState) showRipView() {
s.stopPreview()
s.lastModule = s.active
s.active = "rip"
if cfg, err := loadPersistedRipConfig(); err == nil {
s.applyRipConfig(cfg)
} else if !errors.Is(err, os.ErrNotExist) {
logging.Debug(logging.CatSystem, "failed to load persisted rip config: %v", err)
}
if s.ripFormat == "" {
s.ripFormat = ripFormatLosslessMKV
}
@ -78,6 +137,7 @@ func buildRipView(state *appState) fyne.CanvasObject {
state.ripFormat = val
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
outputEntry.SetText(state.ripOutputPath)
state.persistRipConfig()
})
formatSelect.SetSelected(state.ripFormat)
@ -122,6 +182,45 @@ func buildRipView(state *appState) fyne.CanvasObject {
})
runNowBtn.Importance = widget.HighImportance
applyControls := func() {
formatSelect.SetSelected(state.ripFormat)
outputEntry.SetText(state.ripOutputPath)
}
loadCfgBtn := widget.NewButton("Load Config", func() {
cfg, err := loadPersistedRipConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
} else {
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
}
return
}
state.applyRipConfig(cfg)
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
applyControls()
})
saveCfgBtn := widget.NewButton("Save Config", func() {
cfg := ripConfig{
Format: state.ripFormat,
}
if err := savePersistedRipConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
return
}
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("rip")), state.window)
})
resetBtn := widget.NewButton("Reset", func() {
cfg := defaultRipConfig()
state.applyRipConfig(cfg)
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
applyControls()
state.persistRipConfig()
})
controls := container.NewVBox(
widget.NewLabelWithStyle("Source", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
ui.NewDroppable(sourceEntry, func(items []fyne.URI) {
@ -139,6 +238,8 @@ func buildRipView(state *appState) fyne.CanvasObject {
outputEntry,
container.NewHBox(addQueueBtn, runNowBtn),
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
widget.NewSeparator(),
widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
statusLabel,
progressBar,

View File

@ -3,6 +3,8 @@ package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@ -14,8 +16,10 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"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/logging"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
@ -32,11 +36,80 @@ type subtitleCue struct {
Text string
}
type subtitlesConfig struct {
OutputMode string `json:"outputMode"`
ModelPath string `json:"modelPath"`
BackendPath string `json:"backendPath"`
BurnOutput string `json:"burnOutput"`
}
func defaultSubtitlesConfig() subtitlesConfig {
return subtitlesConfig{
OutputMode: subtitleModeExternal,
ModelPath: "",
BackendPath: "",
BurnOutput: "",
}
}
func loadPersistedSubtitlesConfig() (subtitlesConfig, error) {
var cfg subtitlesConfig
path := moduleConfigPath("subtitles")
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
if cfg.OutputMode == "" {
cfg.OutputMode = subtitleModeExternal
}
return cfg, nil
}
func savePersistedSubtitlesConfig(cfg subtitlesConfig) error {
path := moduleConfigPath("subtitles")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (s *appState) applySubtitlesConfig(cfg subtitlesConfig) {
s.subtitleOutputMode = cfg.OutputMode
s.subtitleModelPath = cfg.ModelPath
s.subtitleBackendPath = cfg.BackendPath
s.subtitleBurnOutput = cfg.BurnOutput
}
func (s *appState) persistSubtitlesConfig() {
cfg := subtitlesConfig{
OutputMode: s.subtitleOutputMode,
ModelPath: s.subtitleModelPath,
BackendPath: s.subtitleBackendPath,
BurnOutput: s.subtitleBurnOutput,
}
if err := savePersistedSubtitlesConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist subtitles config: %v", err)
}
}
func (s *appState) showSubtitlesView() {
s.stopPreview()
s.lastModule = s.active
s.active = "subtitles"
if cfg, err := loadPersistedSubtitlesConfig(); err == nil {
s.applySubtitlesConfig(cfg)
} else if !errors.Is(err, os.ErrNotExist) {
logging.Debug(logging.CatSystem, "failed to load persisted subtitles config: %v", err)
}
if s.subtitleOutputMode == "" {
s.subtitleOutputMode = subtitleModeExternal
}
@ -80,6 +153,7 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
modelEntry.SetText(state.subtitleModelPath)
modelEntry.OnChanged = func(val string) {
state.subtitleModelPath = strings.TrimSpace(val)
state.persistSubtitlesConfig()
}
backendEntry := widget.NewEntry()
@ -87,6 +161,7 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
backendEntry.SetText(state.subtitleBackendPath)
backendEntry.OnChanged = func(val string) {
state.subtitleBackendPath = strings.TrimSpace(val)
state.persistSubtitlesConfig()
}
outputEntry := widget.NewEntry()
@ -94,6 +169,7 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
outputEntry.SetText(state.subtitleBurnOutput)
outputEntry.OnChanged = func(val string) {
state.subtitleBurnOutput = strings.TrimSpace(val)
state.persistSubtitlesConfig()
}
statusLabel := widget.NewLabel("")
@ -255,6 +331,7 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
[]string{subtitleModeExternal, subtitleModeEmbed, subtitleModeBurn},
func(val string) {
state.subtitleOutputMode = val
state.persistSubtitlesConfig()
},
)
outputModeSelect.SetSelected(state.subtitleOutputMode)
@ -264,6 +341,48 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
})
applyBtn.Importance = widget.HighImportance
applyControls := func() {
outputModeSelect.SetSelected(state.subtitleOutputMode)
backendEntry.SetText(state.subtitleBackendPath)
modelEntry.SetText(state.subtitleModelPath)
outputEntry.SetText(state.subtitleBurnOutput)
}
loadCfgBtn := widget.NewButton("Load Config", func() {
cfg, err := loadPersistedSubtitlesConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
} else {
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
}
return
}
state.applySubtitlesConfig(cfg)
applyControls()
})
saveCfgBtn := widget.NewButton("Save Config", func() {
cfg := subtitlesConfig{
OutputMode: state.subtitleOutputMode,
ModelPath: state.subtitleModelPath,
BackendPath: state.subtitleBackendPath,
BurnOutput: state.subtitleBurnOutput,
}
if err := savePersistedSubtitlesConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
return
}
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("subtitles")), state.window)
})
resetBtn := widget.NewButton("Reset", func() {
cfg := defaultSubtitlesConfig()
state.applySubtitlesConfig(cfg)
applyControls()
state.persistSubtitlesConfig()
})
left := container.NewVBox(
widget.NewLabelWithStyle("Sources", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
ui.NewDroppable(videoEntry, handleDrop),
@ -278,6 +397,8 @@ func buildSubtitlesView(state *appState) fyne.CanvasObject {
applyBtn,
widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
statusLabel,
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
)
right := container.NewBorder(