feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
Build Windows Installer / build (push) Successful in 4m11s
This commit is contained in:
40 files changed
+6084
-266
No files matched your search
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"convert/internal/config"
|
||||
"convert/internal/convert"
|
||||
"convert/internal/media"
|
||||
"convert/internal/preview"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type AppInfo struct {
|
||||
Version string `json:"version"`
|
||||
Binaries media.Binaries `json:"binaries"`
|
||||
Config config.AppConfig `json:"config"`
|
||||
InitialFile string `json:"initialFile"`
|
||||
}
|
||||
|
||||
type LoadedVideo struct {
|
||||
Info *media.VideoInfo `json:"info"`
|
||||
VideoID string `json:"videoId"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
ProxyMergedURL string `json:"proxyMergedUrl"`
|
||||
ThumbBaseURL string `json:"thumbBaseUrl"`
|
||||
DefaultOutputPath string `json:"defaultOutputPath"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
initialFile string
|
||||
cfg config.AppConfig
|
||||
bins media.Binaries
|
||||
previewSrv *preview.Server
|
||||
runner *convert.Runner
|
||||
|
||||
mu sync.RWMutex
|
||||
currentInfo *media.VideoInfo
|
||||
}
|
||||
|
||||
func NewApp(initialFile string) *App {
|
||||
cfg := config.Load()
|
||||
bins := media.FindBinaries(cfg.FFmpegPath, cfg.FFprobePath)
|
||||
return &App{
|
||||
initialFile: initialFile,
|
||||
cfg: cfg,
|
||||
bins: bins,
|
||||
runner: convert.NewRunner(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
srv, err := preview.Start(a.bins.FFmpeg)
|
||||
if err == nil {
|
||||
a.previewSrv = srv
|
||||
}
|
||||
|
||||
runtime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||||
if len(paths) > 0 {
|
||||
runtime.EventsEmit(ctx, "app:file-dropped", paths[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) shutdown(_ context.Context) {
|
||||
if a.runner != nil {
|
||||
a.runner.Cancel()
|
||||
}
|
||||
if a.previewSrv != nil {
|
||||
a.previewSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetAppInfo() AppInfo {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return AppInfo{
|
||||
Version: Version,
|
||||
Binaries: a.bins,
|
||||
Config: a.cfg,
|
||||
InitialFile: a.initialFile,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RefreshBinaries() media.Binaries {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.bins = media.FindBinaries(a.cfg.FFmpegPath, a.cfg.FFprobePath)
|
||||
if a.previewSrv != nil {
|
||||
a.previewSrv.SetFFmpeg(a.bins.FFmpeg)
|
||||
}
|
||||
return a.bins
|
||||
}
|
||||
|
||||
func (a *App) SaveConfig(cfg config.AppConfig) (AppInfo, error) {
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return AppInfo{}, err
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.cfg = cfg
|
||||
a.bins = media.FindBinaries(cfg.FFmpegPath, cfg.FFprobePath)
|
||||
if a.previewSrv != nil {
|
||||
a.previewSrv.SetFFmpeg(a.bins.FFmpeg)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return a.GetAppInfo(), nil
|
||||
}
|
||||
|
||||
func (a *App) OpenVideoDialog() (string, error) {
|
||||
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Video File",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Video Files (*.mp4, *.mkv, *.mov, *.avi, *.webm, *.m4v, *.ts, *.flv)",
|
||||
Pattern: "*.mp4;*.mkv;*.mov;*.avi;*.webm;*.m4v;*.ts;*.flv;*.wmv",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) SaveVideoDialog(defaultPath string) (string, error) {
|
||||
dir := filepath.Dir(defaultPath)
|
||||
file := filepath.Base(defaultPath)
|
||||
return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "Save Output Video",
|
||||
DefaultDirectory: dir,
|
||||
DefaultFilename: file,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "MP4 Video (*.mp4)",
|
||||
Pattern: "*.mp4",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) SelectFFmpegDialog() (AppInfo, error) {
|
||||
selected, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Locate ffmpeg.exe",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "ffmpeg executable", Pattern: "ffmpeg.exe;ffmpeg"},
|
||||
{DisplayName: "All Files", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
if err != nil || selected == "" {
|
||||
return a.GetAppInfo(), err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.cfg.FFmpegPath = selected
|
||||
dir := filepath.Dir(selected)
|
||||
probeName := "ffprobe"
|
||||
if goruntime.GOOS == "windows" {
|
||||
probeName = "ffprobe.exe"
|
||||
}
|
||||
a.cfg.FFprobePath = filepath.Join(dir, probeName)
|
||||
_ = config.Save(a.cfg)
|
||||
a.bins = media.FindBinaries(a.cfg.FFmpegPath, a.cfg.FFprobePath)
|
||||
if a.previewSrv != nil {
|
||||
a.previewSrv.SetFFmpeg(a.bins.FFmpeg)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return a.GetAppInfo(), nil
|
||||
}
|
||||
|
||||
func (a *App) LoadVideo(path string) (*LoadedVideo, error) {
|
||||
a.mu.RLock()
|
||||
ffprobeBin := a.bins.FFprobe
|
||||
suffix := a.cfg.OutputSuffix
|
||||
a.mu.RUnlock()
|
||||
|
||||
info, err := media.Probe(ffprobeBin, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.currentInfo = info
|
||||
a.mu.Unlock()
|
||||
|
||||
if a.previewSrv == nil {
|
||||
return nil, fmt.Errorf("preview server is not running")
|
||||
}
|
||||
|
||||
id := a.previewSrv.Register(info)
|
||||
base := a.previewSrv.BaseURL()
|
||||
|
||||
return &LoadedVideo{
|
||||
Info: info,
|
||||
VideoID: id,
|
||||
PreviewURL: fmt.Sprintf("%s/video?id=%s", base, id),
|
||||
ProxyURL: fmt.Sprintf("%s/proxy?id=%s&merge=0", base, id),
|
||||
ProxyMergedURL: fmt.Sprintf("%s/proxy?id=%s&merge=1", base, id),
|
||||
ThumbBaseURL: fmt.Sprintf("%s/thumb?id=%s", base, id),
|
||||
DefaultOutputPath: convert.DefaultOutputPath(path, suffix),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) PreviewCommand(req convert.Request) (string, error) {
|
||||
a.mu.RLock()
|
||||
info := a.currentInfo
|
||||
a.mu.RUnlock()
|
||||
if info == nil {
|
||||
return "", nil
|
||||
}
|
||||
built, err := convert.BuildCommand(req, info)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := make([]string, 0, len(built.Args)+1)
|
||||
parts = append(parts, "ffmpeg")
|
||||
for _, arg := range built.Args {
|
||||
if strings.ContainsAny(arg, " \t") {
|
||||
parts = append(parts, `"`+arg+`"`)
|
||||
} else {
|
||||
parts = append(parts, arg)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
func (a *App) StartConvert(req convert.Request) error {
|
||||
a.mu.RLock()
|
||||
info := a.currentInfo
|
||||
ffmpegBin := a.bins.FFmpeg
|
||||
a.mu.RUnlock()
|
||||
|
||||
if info == nil {
|
||||
return fmt.Errorf("no video loaded")
|
||||
}
|
||||
if ffmpegBin == "" {
|
||||
return fmt.Errorf("ffmpeg binary not found")
|
||||
}
|
||||
if a.runner.IsRunning() {
|
||||
return fmt.Errorf("conversion already running")
|
||||
}
|
||||
|
||||
built, err := convert.BuildCommand(req, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := a.runner.Run(a.ctx, ffmpegBin, built, func(p convert.Progress) {
|
||||
runtime.EventsEmit(a.ctx, "convert:progress", p)
|
||||
})
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "convert:error", err.Error())
|
||||
return
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "convert:done", built.OutputPath)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CancelConvert() bool {
|
||||
return a.runner.Cancel()
|
||||
}
|
||||
|
||||
func (a *App) OpenInExplorer(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if goruntime.GOOS == "windows" {
|
||||
cmd := exec.Command("explorer", "/select,", filepath.Clean(path))
|
||||
return cmd.Start()
|
||||
}
|
||||
return exec.Command("xdg-open", filepath.Dir(path)).Start()
|
||||
}
|
||||
|
||||
func (a *App) OpenOutputFile(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if goruntime.GOOS == "windows" {
|
||||
cmd := exec.Command("cmd", "/c", "start", "", filepath.Clean(path))
|
||||
media.HideCmdWindow(cmd)
|
||||
return cmd.Start()
|
||||
}
|
||||
return exec.Command("xdg-open", path).Start()
|
||||
}
|
||||
Reference in new issue
Block a user